diff --git a/.env.development b/.env.development index 4c85dbbe..ac0b75e2 100644 --- a/.env.development +++ b/.env.development @@ -1,7 +1,7 @@ NODE_ENV='development' -PORT=8080 +PORT=1998 ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' -BASE_URL='http://localhost:8080' +BASE_URL='http://localhost:1998' CREDENTIALS_BASE_URL='http://localhost:18150' CSRF_TOKEN_API_PATH='/csrf/api/v1/token' ECOMMERCE_BASE_URL='http://localhost:18130' diff --git a/.env.test b/.env.test index f8c2df3d..e00f6c61 100644 --- a/.env.test +++ b/.env.test @@ -1,6 +1,7 @@ ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' -BASE_URL='http://localhost:1995' +BASE_URL='http://localhost:1998' CREDENTIALS_BASE_URL='http://localhost:18150' +SUPPORT_URL='http://support.example.com' CSRF_TOKEN_API_PATH='/csrf/api/v1/token' ECOMMERCE_BASE_URL='http://localhost:18130' LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference' diff --git a/.stylelintrc.json b/.stylelintrc.json index 3990f772..9c783ec9 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -30,6 +30,7 @@ "property-no-unknown": [true, { "ignoreProperties": ["xs", "sm", "md", "lg", "xl", "xxl"] }], - "alpha-value-notation": "number" + "alpha-value-notation": "number", + "string-quotes": "double" } } diff --git a/README.rst b/README.rst index 1c5aa70f..e7f91812 100644 --- a/README.rst +++ b/README.rst @@ -7,7 +7,7 @@ frontend-app-catalog Purpose ******* -This is the Catalog micro-frontend application, currently under development. +This is the Catalog micro-frontend application, currently under development by `2U `_. **What is the domain of this MFE?** @@ -70,6 +70,13 @@ The dev server is running at `http://apps.local.openedx.io:1998/catalog/ `_. If you start Tutor with ``tutor dev start catalog`` that should give you everything you need as a companion to this frontend. +.. |Build Status| image:: https://api.travis-ci.com/edx/frontend-app-catalog.svg?branch=master + :target: https://travis-ci.com/edx/frontend-app-catalog +.. |Codecov| image:: https://codecov.io/gh/edx/frontend-app-catalog/branch/master/graph/badge.svg + :target: https://codecov.io/gh/edx/frontend-app-catalog +.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-catalog.svg + :target: @edx/frontend-app-catalog + Internationalization ==================== diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 00000000..5f7173a6 --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,118 @@ +import { useCourseDiscovery } from './data/course-discovery/hooks'; +import { useHomeSettingsQuery } from './home/data/hooks'; +import { mockHomeSettingsResponse } from './home/__mocks__'; +import { mockCourseDiscoveryResponse } from './__mocks__'; +import { + render, within, waitFor, screen, +} from './setupTest'; +import { ROUTES } from './routes'; +import App from './App'; + +import messages from './сatalog/messages'; + +jest.mock('@edx/frontend-platform', () => ({ + getAuthenticatedUser: jest.fn(() => ({ username: 'test-user', roles: [] })), + getConfig: jest.fn(() => ({ + LMS_BASE_URL: '', + ENABLE_PROGRAMS: true, + ENABLE_COURSE_DISCOVERY: true, + })), +})); + +jest.mock('./home/data/hooks', () => ({ + useHomeSettingsQuery: jest.fn(), +})); + +jest.mock('./сatalog/data/hooks', () => ({ + useCourseDiscovery: jest.fn(), +})); + +const mockHomeSettings = useHomeSettingsQuery as jest.Mock; +const mockCourseDiscovery = useCourseDiscovery as jest.Mock; + +jest.mock('@edx/frontend-platform/react', () => ({ + AppProvider: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +jest.mock('@edx/frontend-component-header', () => function getHeader() { + return
; +}); + +jest.mock('@edx/frontend-component-footer', () => ({ + FooterSlot: () =>
, +})); + +describe('App', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + mockHomeSettings.mockReturnValue({ + data: mockHomeSettingsResponse, + isLoading: false, + isError: false, + }); + + mockCourseDiscovery.mockReturnValue({ + data: mockCourseDiscoveryResponse, + isLoading: false, + isError: false, + }); + + it('renders HomePage on "/" route', async () => { + window.testHistory = [ROUTES.HOME]; + + render(); + + await waitFor(() => { + expect(screen.queryByTestId('spinner')).not.toBeInTheDocument(); + }); + + expect(screen.getByTestId('home-banner')).toBeInTheDocument(); + }); + + it('renders CatalogPage with course cards at /courses route', async () => { + window.testHistory = [ROUTES.COURSES]; + + render(); + + await waitFor(() => { + expect(screen.queryByTestId('spinner')).not.toBeInTheDocument(); + }); + + expect( + screen.getByText( + messages.totalCoursesHeading.defaultMessage.replace( + '{totalCourses}', + mockCourseDiscoveryResponse.results.length, + ), + ), + ).toBeInTheDocument(); + + const courseCards = screen.getAllByRole('link'); + expect(courseCards.length).toBe(mockCourseDiscoveryResponse.results.length); + + courseCards.forEach((card, index) => { + const course = mockCourseDiscoveryResponse.results[index]; + const cardContent = within(card); + + expect(card).toHaveAttribute('href', `/courses/${course.id}/about`); + expect(cardContent.getByText(course.data.content.displayName)).toBeInTheDocument(); + expect(cardContent.getByText(course.data.org)).toBeInTheDocument(); + }); + }); + + it('renders CourseAboutPage on "/courses/some-course-id/about"', () => { + window.testHistory = [ROUTES.COURSE_ABOUT]; + + render(); + expect(screen.getByTestId('course-about-page')).toBeInTheDocument(); + }); + + it('renders NotFoundPage on unknown route', () => { + window.testHistory = ['/some-unknown-path']; + + render(); + expect(screen.getByTestId('not-found-page')).toBeInTheDocument(); + }); +}); diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 00000000..6e3bd6a7 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,32 @@ +import { AppProvider } from '@edx/frontend-platform/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { Route, Routes } from 'react-router-dom'; +import { FooterSlot } from '@edx/frontend-component-footer'; +import Header from '@edx/frontend-component-header'; + +import HomePage from './home/HomePage'; +import CatalogPage from './сatalog/CatalogPage'; +import CourseAboutPage from './course-about/CourseAboutPage'; +import NotFoundPage from './not-found-page/NotFoundPage'; +import { ROUTES } from './routes'; + +const queryClient = new QueryClient(); + +const App = () => ( + + +
+
+ + } /> + } /> + } /> + } /> + +
+ + + +); + +export default App; diff --git a/src/__mocks__/course.ts b/src/__mocks__/course.ts new file mode 100644 index 00000000..07ee71de --- /dev/null +++ b/src/__mocks__/course.ts @@ -0,0 +1,20 @@ +export const mockCourseResponse = { + id: 'course-v1:edX+DemoX+Demo_Course', + data: { + id: 'course-v1:edX+DemoX+Demo_Course', + course: 'Demo Course', + 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', + content: { + displayName: 'Demonstration Course', + overview: 'Course overview', + number: 'DemoX', + }, + number: 'DemoX', + modes: ['audit', 'verified'], + language: 'en', + catalogVisibility: 'both', + }, +}; diff --git a/src/__mocks__/courseDiscovery.ts b/src/__mocks__/courseDiscovery.ts new file mode 100644 index 00000000..e812460e --- /dev/null +++ b/src/__mocks__/courseDiscovery.ts @@ -0,0 +1,100 @@ +export const mockCourseDiscoveryResponse = { + took: 1, + total: 3, + results: [ + { + id: 'course-v1:OpenEdx+123+2023', + index: 'course_info', + type: '_doc', + data: { + id: 'course-v1:OpenEdx+123+2023', + course: 'course-v1:OpenEdx+123+2023', + content: { + displayName: 'Test course 1', + overview: 'About This Course Include your long course description here. The long course description should contain 150-400 words. This is paragraph 2 of the long course description. Add more paragraphs as needed. Make sure to enclose them in paragraph tags. Requirements Add information about the skills and knowledge students need to take this course. Course Staff Staff Member #1 Biography of instructor/staff member #1 Staff Member #2 Biography of instructor/staff member #2 Frequently Asked Questions What web browser should I use? The Open edX platform works best with current versions of Chrome, Edge, Firefox, or Safari. See our list of supported browsers for the most up-to-date information. Question #2 Your answer would be displayed here. ', + number: '123', + }, + imageUrl: '/asset-v1:OpenEdx+123+2023+type@asset+block@images_course_image.jpg', + start: '2030-01-01T00:00:00', + number: '123', + org: 'OpenEdx', + modes: [ + 'audit', + ], + language: 'en', + catalogVisibility: 'both', + }, + }, + { + id: 'course-v1:OpenEdx+123+2024', + index: 'course_info', + type: '_doc', + data: { + id: 'course-v1:OpenEdx+123+2024', + course: 'course-v1:OpenEdx+123+2024', + content: { + displayName: 'Course test 2', + overview: 'About This Course Include your long course description here. The long course description should contain 150-400 words. This is paragraph 2 of the long course description. Add more paragraphs as needed. Make sure to enclose them in paragraph tags. Requirements Add information about the skills and knowledge students need to take this course. Course Staff Staff Member #1 Biography of instructor/staff member #1 Staff Member #2 Biography of instructor/staff member #2 Frequently Asked Questions What web browser should I use? The Open edX platform works best with current versions of Chrome, Edge, Firefox, or Safari. See our list of supported browsers for the most up-to-date information. Question #2 Your answer would be displayed here. ', + number: '312', + }, + imageUrl: '/asset-v1:OpenEdx+123+2024+type@asset+block@images_course_image.jpg', + start: '2030-01-01T00:00:00', + number: '312', + org: 'OpenEdx', + modes: [ + 'audit', + ], + language: 'en', + catalogVisibility: 'both', + }, + }, + { + id: 'course-v1:dev+654+2024', + index: 'course_info', + type: '_doc', + data: { + id: 'course-v1:dev+654+2024', + course: 'course-v1:dev+654+2024', + content: { + displayName: 'Course test 3', + overview: 'About This Course Include your long course description here. The long course description should contain 150-400 words. This is paragraph 2 of the long course description. Add more paragraphs as needed. Make sure to enclose them in paragraph tags. Requirements Add information about the skills and knowledge students need to take this course. Course Staff Staff Member #1 Biography of instructor/staff member #1 Staff Member #2 Biography of instructor/staff member #2 Frequently Asked Questions What web browser should I use? The Open edX platform works best with current versions of Chrome, Edge, Firefox, or Safari. See our list of supported browsers for the most up-to-date information. Question #2 Your answer would be displayed here. ', + number: '654', + }, + imageUrl: '/asset-v1:dev+654+2024+type@asset+block@images_course_image.jpg', + start: '2030-01-01T00:00:00', + number: '654', + org: 'dev', + modes: [ + 'audit', + ], + language: 'en', + catalogVisibility: 'both', + }, + }, + ], + aggs: { + language: { + terms: { + en: 3, + }, + total: 3, + other: 0, + }, + modes: { + terms: { + audit: 3, + }, + total: 3, + other: 0, + }, + org: { + terms: { + dev: 1, + openedx: 1, + }, + total: 3, + other: 0, + }, + }, + maxScore: 1.0, +}; diff --git a/src/__mocks__/index.ts b/src/__mocks__/index.ts new file mode 100644 index 00000000..52b86516 --- /dev/null +++ b/src/__mocks__/index.ts @@ -0,0 +1,2 @@ +export { mockCourseResponse } from './course'; +export { mockCourseDiscoveryResponse } from './courseDiscovery'; diff --git a/src/assets/images/home-banner.jpg b/src/assets/images/home-banner.jpg new file mode 100644 index 00000000..fbaeb1b5 Binary files /dev/null and b/src/assets/images/home-banner.jpg differ diff --git a/src/assets/images/no-course-image.svg b/src/assets/images/no-course-image.svg new file mode 100644 index 00000000..0efd99a3 --- /dev/null +++ b/src/assets/images/no-course-image.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/images/no-org-image.svg b/src/assets/images/no-org-image.svg new file mode 100644 index 00000000..139c11f9 --- /dev/null +++ b/src/assets/images/no-org-image.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/scss/_animations.scss b/src/assets/scss/_animations.scss new file mode 100644 index 00000000..9f55d382 --- /dev/null +++ b/src/assets/scss/_animations.scss @@ -0,0 +1,23 @@ +@keyframes home-banner-info { + 0% { + opacity: 0; + transform: translateY(18.75rem); + } + + 45% { + opacity: 1; + } + + 65% { + transform: translateY(-2.5rem); + } + + 85% { + transform: translateY(.625rem); + } + + 100% { + top: 0; + transform: translateY(0); + } +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 00000000..d071a00a --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,15 @@ +/** + * Feature policy for iframe, allowing access to certain courseware-related media. + * + * We must use the wildcard (*) origin for each feature, as courseware content + * may be embedded in external iframes. Notably, xblock-lti-consumer is a popular + * block that iframes external course content. + + * This policy was selected in conference with the edX Security Working Group. + * Changes to it should be vetted by them (security@edx.org). + */ +export const IFRAME_FEATURE_POLICY = ( + 'microphone *; camera *; midi *; geolocation *; encrypted-media *; clipboard-write *' +); + +export const DEFAULT_VIDEO_MODAL_HEIGHT = 500; diff --git a/src/custom.d.ts b/src/custom.d.ts new file mode 100644 index 00000000..46ae207a --- /dev/null +++ b/src/custom.d.ts @@ -0,0 +1,26 @@ +type StaticImageData = string; + +declare module '*.png' { + const content: StaticImageData; + export default content; +} + +declare module '*.jpg' { + const content: StaticImageData; + export default content; +} + +declare module '*.jpeg' { + const content: StaticImageData; + export default content; +} + +declare module '*.svg' { + const content: StaticImageData; + export default content; +} + +declare module '*.gif' { + const content: StaticImageData; + export default content; +} diff --git a/src/data/course-discovery/__tests__/courseDiscovery.test.tsx b/src/data/course-discovery/__tests__/courseDiscovery.test.tsx new file mode 100644 index 00000000..3d702af5 --- /dev/null +++ b/src/data/course-discovery/__tests__/courseDiscovery.test.tsx @@ -0,0 +1,120 @@ +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 { getCourseDiscoveryUrl } from '../urls'; + +jest.mock('@edx/frontend-platform/auth', () => ({ + getAuthenticatedHttpClient: jest.fn(), +})); + +const mockGetAuthenticatedHttpClient = getAuthenticatedHttpClient as jest.Mock; + +describe('Course Discovery Data Layer', () => { + describe('fetchCourseDiscovery', () => { + beforeEach(() => jest.clearAllMocks()); + + it('should fetch course discovery data with default parameters', async () => { + const mockPost = jest.fn().mockResolvedValue({ data: mockCourseDiscoveryResponse }); + mockGetAuthenticatedHttpClient.mockReturnValue({ post: mockPost }); + + const result = await fetchCourseDiscovery(); + + expect(mockPost).toHaveBeenCalledTimes(1); + const [url] = mockPost.mock.calls[0]; + + expect(url).toBe(getCourseDiscoveryUrl()); + expect(result).toEqual(mockCourseDiscoveryResponse); + }); + + it('should fetch course discovery data with custom parameters', async () => { + const mockPost = jest.fn().mockResolvedValue({ data: mockCourseDiscoveryResponse }); + mockGetAuthenticatedHttpClient.mockReturnValue({ post: mockPost }); + + const customPageSize = 21; + const customPageIndex = 2; + + await fetchCourseDiscovery(customPageSize, customPageIndex, true); + + const [url, formData] = mockPost.mock.calls[0]; + + expect(url).toBe(getCourseDiscoveryUrl()); + + expect((formData as FormData).get('page_size')).toBe(String(customPageSize)); + expect((formData as FormData).get('page_index')).toBe(String(customPageIndex)); + expect((formData as FormData).get('enable_course_sorting_by_start_date')).toBe('true'); + }); + + it('should handle API errors', async () => { + const error = new Error('API Error'); + const mockPost = jest.fn().mockRejectedValue(error); + mockGetAuthenticatedHttpClient.mockReturnValue({ post: mockPost }); + + await expect(fetchCourseDiscovery()).rejects.toThrow('API Error'); + }); + }); + + describe('useCourseDiscovery', () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + beforeEach(() => { + jest.clearAllMocks(); + queryClient.clear(); + }); + + it('should return loading state initially', () => { + const mockPost = jest.fn().mockResolvedValue({ data: mockCourseDiscoveryResponse }); + mockGetAuthenticatedHttpClient.mockReturnValue({ post: mockPost }); + + const { result } = renderHook(() => useCourseDiscovery(), { 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 }); + mockGetAuthenticatedHttpClient.mockReturnValue({ post: mockPost }); + + const { result } = renderHook(() => useCourseDiscovery(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.data).toEqual(mockCourseDiscoveryResponse); + expect(result.current.isError).toBe(false); + }); + + it('should handle errors', async () => { + const error = new Error('API Error'); + const mockPost = jest.fn().mockRejectedValue(error); + mockGetAuthenticatedHttpClient.mockReturnValue({ post: mockPost }); + + const { result } = renderHook(() => useCourseDiscovery(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.isError).toBe(true); + expect(result.current.error).toEqual(error); + }); + }); +}); diff --git a/src/data/course-discovery/api.ts b/src/data/course-discovery/api.ts new file mode 100644 index 00000000..0351079b --- /dev/null +++ b/src/data/course-discovery/api.ts @@ -0,0 +1,29 @@ +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, + 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( + getCourseDiscoveryUrl(), + formData, + ); + + return camelCaseObject(data); +}; diff --git a/src/data/course-discovery/constants.ts b/src/data/course-discovery/constants.ts new file mode 100644 index 00000000..94659b82 --- /dev/null +++ b/src/data/course-discovery/constants.ts @@ -0,0 +1,2 @@ +export const DEFAULT_PAGE_SIZE = 20; +export const DEFAULT_PAGE_INDEX = 0; diff --git a/src/data/course-discovery/hooks.ts b/src/data/course-discovery/hooks.ts new file mode 100644 index 00000000..1098221f --- /dev/null +++ b/src/data/course-discovery/hooks.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query'; + +import { fetchCourseDiscovery } from './api'; +import { CourseDiscoveryResponse } from './types'; +import { DEFAULT_PAGE_INDEX, DEFAULT_PAGE_SIZE } from './constants'; + +/** + * A React Query hook that fetches course discovery data. + */ +export const useCourseDiscovery = ({ + pageSize, + pageIndex, + enableCourseSortingByStartDate, +} = { + pageSize: DEFAULT_PAGE_SIZE, + pageIndex: DEFAULT_PAGE_INDEX, + enableCourseSortingByStartDate: false, +}) => useQuery({ + queryKey: ['courseDiscovery'], + queryFn: () => fetchCourseDiscovery( + pageSize, + pageIndex, + enableCourseSortingByStartDate, + ), +}); diff --git a/src/data/course-discovery/types.ts b/src/data/course-discovery/types.ts new file mode 100644 index 00000000..74247ba4 --- /dev/null +++ b/src/data/course-discovery/types.ts @@ -0,0 +1,23 @@ +export interface CourseDiscoveryResponse { + total: number; + results: { + id: string; + title: string; + data: { + id: string; + course: string; + start: string; + imageUrl: string; + org: string; + content: { + displayName: string; + overview?: string; + number?: string; + }; + number: string; + modes: string[]; + language: string; + catalogVisibility: string; + }; + }[]; +} diff --git a/src/data/course-discovery/urls.ts b/src/data/course-discovery/urls.ts new file mode 100644 index 00000000..e0db55d4 --- /dev/null +++ b/src/data/course-discovery/urls.ts @@ -0,0 +1,5 @@ +import { getConfig } from '@edx/frontend-platform'; + +export const getApiBaseUrl = () => getConfig().LMS_BASE_URL; + +export const getCourseDiscoveryUrl = () => `${getApiBaseUrl()}/search/course_discovery/`; diff --git a/src/generic/alert-notification/AlertNotification.test.tsx b/src/generic/alert-notification/AlertNotification.test.tsx new file mode 100644 index 00000000..0c3544e1 --- /dev/null +++ b/src/generic/alert-notification/AlertNotification.test.tsx @@ -0,0 +1,39 @@ +import { render } from '../../setupTest'; +import { AlertNotificationProps } from './types'; +import { AlertNotification } from '.'; + +const renderComponent = (props: AlertNotificationProps) => render(); + +describe('AlertNotification', () => { + it('renders with default props', () => { + const { getByText, getByRole } = renderComponent({ + title: 'Test Title', + message: 'Test Message', + variant: 'info', + }); + + expect(getByText('Test Title')).toBeInTheDocument(); + expect(getByText('Test Message')).toBeInTheDocument(); + expect(getByRole('alert')).toHaveClass('alert-info'); + }); + + it('renders with custom variant', () => { + const { getByRole } = renderComponent({ + title: 'Warning Title', + message: 'Warning Message', + variant: 'warning', + }); + + expect(getByRole('alert')).toHaveClass('alert-warning'); + }); + + it('displays the info icon', () => { + const { getByRole } = renderComponent({ + title: 'Alert with Icon', + message: 'Has info icon', + variant: 'info', + }); + + expect(getByRole('alert').querySelector('svg')).toBeInTheDocument(); + }); +}); diff --git a/src/generic/alert-notification/index.tsx b/src/generic/alert-notification/index.tsx new file mode 100644 index 00000000..ca7ae141 --- /dev/null +++ b/src/generic/alert-notification/index.tsx @@ -0,0 +1,13 @@ +import { Alert as BaseAlert } from '@openedx/paragon'; +import { Info as InfoIcon } from '@openedx/paragon/icons'; + +import { AlertNotificationProps } from './types'; + +export const AlertNotification = ({ + variant = 'info', title, message, +}: AlertNotificationProps) => ( + + {title} +

{message}

+
+); diff --git a/src/generic/alert-notification/types.ts b/src/generic/alert-notification/types.ts new file mode 100644 index 00000000..bb7a4b12 --- /dev/null +++ b/src/generic/alert-notification/types.ts @@ -0,0 +1,5 @@ +export interface AlertNotificationProps { + variant?: string; + title: string; + message: string; +} diff --git a/src/generic/course-card/CourseCard.test.tsx b/src/generic/course-card/CourseCard.test.tsx new file mode 100644 index 00000000..3390d925 --- /dev/null +++ b/src/generic/course-card/CourseCard.test.tsx @@ -0,0 +1,56 @@ +import { getConfig } from '@edx/frontend-platform'; + +import { mockCourseResponse } from '../../__mocks__'; +import { render } from '../../setupTest'; +import { ROUTES } from '../../routes'; +import { CourseCard } from '.'; + +import messages from './messages'; + +describe('CourseCard', () => { + const renderComponent = (course = mockCourseResponse) => render( + , + ); + + it('renders course information correctly', () => { + const { getByText } = renderComponent(); + + expect(getByText(mockCourseResponse.data.content.displayName)).toBeInTheDocument(); + expect(getByText(mockCourseResponse.data.org)).toBeInTheDocument(); + expect(getByText('Starts: Apr 1, 2024')).toBeInTheDocument(); + }); + + it('renders course image with correct src and fallback', () => { + const { getByAltText } = renderComponent(); + + const image = getByAltText(mockCourseResponse.data.content.displayName); + expect(image).toHaveAttribute('src', `${getConfig().LMS_BASE_URL}${mockCourseResponse.data.imageUrl}`); + }); + + it('renders organization logo with correct src and fallback', () => { + const { getByAltText } = renderComponent(); + + const logo = getByAltText(mockCourseResponse.data.org); + expect(logo).toHaveAttribute('src', `${getConfig().LMS_BASE_URL}${mockCourseResponse.data.orgImg}`); + }); + + it('formats the link destination correctly', () => { + const { getByRole } = renderComponent(); + + const link = getByRole('link'); + expect(link).toHaveAttribute('href', ROUTES.COURSE_ABOUT.replace(':courseId', mockCourseResponse.id)); + }); + + it('handles missing start date gracefully', () => { + const courseWithoutStart = { + ...mockCourseResponse, + data: { + ...mockCourseResponse.data, + start: '', + }, + }; + const { queryByText } = renderComponent(courseWithoutStart); + + expect(queryByText(messages.startDate.defaultMessage.replace('{startDate}', courseWithoutStart.data.start))).not.toBeInTheDocument(); + }); +}); diff --git a/src/generic/course-card/constants.ts b/src/generic/course-card/constants.ts new file mode 100644 index 00000000..12709ec6 --- /dev/null +++ b/src/generic/course-card/constants.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000..266642bd --- /dev/null +++ b/src/generic/course-card/index.tsx @@ -0,0 +1,51 @@ +import { Link } from 'react-router-dom'; +import { Card, useMediaQuery, breakpoints } from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { CourseCardProps } from './types'; +import messages from './messages'; +import { getFullImageUrl } from './utils'; +import { DATE_FORMAT_OPTIONS } from './constants'; + +import noCourseImg from '../../assets/images/no-course-image.svg'; +import noOrgImg from '../../assets/images/no-org-image.svg'; + +// 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) => { + 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) + : ''; + + return ( + + + +

{course.data.content.displayName}

+

{course.data.org}

+ {formattedDate && ( + + {intl.formatMessage(messages.startDate, { + startDate: formattedDate, + })} + + )} +
+
+ ); +}; diff --git a/src/generic/course-card/messages.ts b/src/generic/course-card/messages.ts new file mode 100644 index 00000000..82cc5c1a --- /dev/null +++ b/src/generic/course-card/messages.ts @@ -0,0 +1,11 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + startDate: { + id: 'generic.course-card.start-date', + defaultMessage: 'Starts: {startDate}', + description: 'Start date.', + }, +}); + +export default messages; diff --git a/src/generic/course-card/types.ts b/src/generic/course-card/types.ts new file mode 100644 index 00000000..7383c2ce --- /dev/null +++ b/src/generic/course-card/types.ts @@ -0,0 +1,30 @@ +export interface CourseContent { + displayName: string; + overview?: string; + number?: string; +} + +export interface CourseData { + id: string; + course: string; + start: string; + imageUrl: string; + org: string; + orgImg?: string; + content: CourseContent; + number: string; + modes: string[]; + language: string; + catalogVisibility: string; +} + +export interface Course { + id: string; + index?: string; + type?: string; + data: CourseData; +} + +export interface CourseCardProps { + course: Course; +} diff --git a/src/generic/course-card/utils.ts b/src/generic/course-card/utils.ts new file mode 100644 index 00000000..009ede0a --- /dev/null +++ b/src/generic/course-card/utils.ts @@ -0,0 +1,11 @@ +import { getConfig } from '@edx/frontend-platform'; + +/** + * Constructs a full URL for an image by combining the LMS base URL with the provided image path. + */ +export const getFullImageUrl = (path?: string) => { + if (!path) { + return ''; + } + return `${getConfig().LMS_BASE_URL}${path}`; +}; diff --git a/src/generic/index.ts b/src/generic/index.ts new file mode 100644 index 00000000..f7f27506 --- /dev/null +++ b/src/generic/index.ts @@ -0,0 +1,5 @@ +export { CourseCard } from './course-card'; +export { SubHeader } from './sub-header'; +export { LoadingSpinner, Loading } from './loading-spinner'; +export { AlertNotification } from './alert-notification'; +export { VideoModal } from './video-modal'; diff --git a/src/generic/loading-spinner/LoadingSpinner.test.tsx b/src/generic/loading-spinner/LoadingSpinner.test.tsx new file mode 100644 index 00000000..155efda5 --- /dev/null +++ b/src/generic/loading-spinner/LoadingSpinner.test.tsx @@ -0,0 +1,41 @@ +import { render, within } from '../../setupTest'; +import { LoadingSpinner, Loading } from '.'; + +import messages from './messages'; + +describe('LoadingSpinner', () => { + it('renders with default size', () => { + const { container } = render(); + const spinner = container.querySelector('.spinner-border'); + + expect(spinner).toBeInTheDocument(); + expect(spinner).toHaveClass('spinner-border-lg'); + }); + + it('renders with custom size', () => { + const { container } = render(); + const spinner = container.querySelector('.spinner-border'); + + expect(spinner).toBeInTheDocument(); + expect(spinner).toHaveClass('spinner-border-sm'); + }); + + it('has correct accessibility attributes', () => { + const { getByRole } = render(); + const spinner = getByRole('status'); + + expect(spinner).toBeInTheDocument(); + expect(within(spinner).getByText(messages.screenReaderText.defaultMessage)).toBeInTheDocument(); + }); +}); + +describe('Loading', () => { + it('renders full page loading spinner with correct styling', () => { + const { container, getByRole } = render(); + const wrapper = container.firstChild; + const spinner = getByRole('status'); + + expect(wrapper).toHaveClass('d-flex', 'justify-content-center', 'align-items-center', 'flex-column', 'flex-grow-1'); + expect(spinner).toBeInTheDocument(); + }); +}); diff --git a/src/generic/loading-spinner/index.tsx b/src/generic/loading-spinner/index.tsx new file mode 100644 index 00000000..c097572b --- /dev/null +++ b/src/generic/loading-spinner/index.tsx @@ -0,0 +1,24 @@ +import { Spinner } from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; +import { LoadingSpinnerProps } from './types'; + +export const LoadingSpinner = ({ size = 'lg' }: LoadingSpinnerProps) => { + const intl = useIntl(); + + return ( + + ); +}; + +export const Loading = () => ( +
+ +
+); diff --git a/src/generic/loading-spinner/messages.ts b/src/generic/loading-spinner/messages.ts new file mode 100644 index 00000000..b9c9b5fd --- /dev/null +++ b/src/generic/loading-spinner/messages.ts @@ -0,0 +1,11 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + screenReaderText: { + id: 'category.generic.loading', + defaultMessage: 'Loading...', + description: 'Screen-reader message for when a page is loading.', + }, +}); + +export default messages; diff --git a/src/generic/loading-spinner/types.ts b/src/generic/loading-spinner/types.ts new file mode 100644 index 00000000..818c16d0 --- /dev/null +++ b/src/generic/loading-spinner/types.ts @@ -0,0 +1,3 @@ +export interface LoadingSpinnerProps { + size?: 'sm' | 'md' | 'lg'; +} diff --git a/src/generic/sub-header/SubHeader.test.tsx b/src/generic/sub-header/SubHeader.test.tsx new file mode 100644 index 00000000..4c214e72 --- /dev/null +++ b/src/generic/sub-header/SubHeader.test.tsx @@ -0,0 +1,23 @@ +import { render } from '../../setupTest'; + +import { SubHeader } from '.'; + +describe('SubHeader', () => { + it('renders without crashing', () => { + const { getByRole } = render(); + expect(getByRole('banner')).toBeInTheDocument(); + }); + + it('displays the provided title', () => { + const testTitle = 'My Test Title'; + const { getByRole } = render(); + expect(getByRole('heading', { level: 1 })).toHaveTextContent(testTitle); + }); + + it('has correct CSS classes', () => { + const { getByRole } = render(); + const header = getByRole('banner'); + expect(header).toHaveClass('sub-header', 'd-flex', 'justify-content-between'); + expect(getByRole('heading', { level: 1 })).toHaveClass('mb-0'); + }); +}); diff --git a/src/generic/sub-header/index.tsx b/src/generic/sub-header/index.tsx new file mode 100644 index 00000000..5dba3625 --- /dev/null +++ b/src/generic/sub-header/index.tsx @@ -0,0 +1,7 @@ +import { SubHeaderProps } from './types'; + +export const SubHeader = ({ title }: SubHeaderProps) => ( +
+

{title}

+
+); diff --git a/src/generic/sub-header/types.ts b/src/generic/sub-header/types.ts new file mode 100644 index 00000000..861c7937 --- /dev/null +++ b/src/generic/sub-header/types.ts @@ -0,0 +1,3 @@ +export interface SubHeaderProps { + title: string; +} diff --git a/src/generic/video-modal/VideoModal.test.tsx b/src/generic/video-modal/VideoModal.test.tsx new file mode 100644 index 00000000..a3446621 --- /dev/null +++ b/src/generic/video-modal/VideoModal.test.tsx @@ -0,0 +1,49 @@ +import { render, userEvent, cleanup } from '../../setupTest'; +import { DEFAULT_VIDEO_MODAL_HEIGHT } from '../../constants'; +import { VideoModal } from '.'; + +import messages from './messages'; + +const videoModalProps = { + isOpen: true, + close: jest.fn(), + videoID: 'some_id', +}; + +describe('', () => { + afterEach(() => { + cleanup(); + jest.clearAllMocks(); + }); + + it('renders modal with correct title and iframe when open', () => { + const { getByTitle } = render(); + const iframe = getByTitle(messages.videoIframeTitle.defaultMessage); + + expect(iframe).toBeInTheDocument(); + expect(iframe).toHaveAttribute('src', expect.stringContaining(videoModalProps.videoID)); + }); + + it('does not render modal when isOpen is false', () => { + const { queryByTitle } = render(); + + expect(queryByTitle(messages.videoIframeTitle.defaultMessage)).not.toBeInTheDocument(); + }); + + it('calls close function when modal backdrop is clicked or esc is pressed (if supported)', async () => { + render(); + + await userEvent.keyboard('{Escape}'); + expect(videoModalProps.close).toHaveBeenCalledTimes(1); + }); + + it('renders iframe with correct attributes', () => { + const { getByTitle } = render(); + const iframe = getByTitle(messages.videoIframeTitle.defaultMessage); + + expect(iframe).toHaveAttribute('width', 'auto'); + expect(iframe).toHaveAttribute('height', DEFAULT_VIDEO_MODAL_HEIGHT); + expect(iframe).toHaveAttribute('frameBorder', '0'); + expect(iframe).toHaveAttribute('allowFullScreen'); + }); +}); diff --git a/src/generic/video-modal/index.tsx b/src/generic/video-modal/index.tsx new file mode 100644 index 00000000..74fb93f1 --- /dev/null +++ b/src/generic/video-modal/index.tsx @@ -0,0 +1,39 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { ModalDialog } from '@openedx/paragon'; + +import { DEFAULT_VIDEO_MODAL_HEIGHT, IFRAME_FEATURE_POLICY } from '../../constants'; +import { VideoModalProps } from './types'; +import messages from './messages'; + +export const VideoModal = ({ + isOpen, + close, + videoID, + size = 'lg', + height = DEFAULT_VIDEO_MODAL_HEIGHT, + width = 'auto', +}: VideoModalProps) => { + const intl = useIntl(); + + return ( + +