Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,8 @@ SITE_NAME=''
USER_INFO_COOKIE_NAME=''
APP_ID='catalog'
MFE_CONFIG_API_URL=''
ENABLE_PROGRAMS=false
SUPPORT_URL=''
INFO_EMAIL=''
# Fallback in local style files
PARAGON_THEME_URLS={}
3 changes: 3 additions & 0 deletions .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,8 @@ SITE_NAME=localhost
USER_INFO_COOKIE_NAME='edx-user-info'
APP_ID='catalog'
MFE_CONFIG_API_URL=''
ENABLE_PROGRAMS=false
SUPPORT_URL=''
INFO_EMAIL='info@example.com'
# Fallback in local style files
PARAGON_THEME_URLS={}
4 changes: 4 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
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'
Expand All @@ -19,4 +20,7 @@ SITE_NAME=localhost
USER_INFO_COOKIE_NAME='edx-user-info'
APP_ID='catalog'
MFE_CONFIG_API_URL=''
ENABLE_PROGRAMS=false
SUPPORT_URL='info@example.com'
INFO_EMAIL=''
PARAGON_THEME_URLS={}
3 changes: 2 additions & 1 deletion .stylelintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
26 changes: 24 additions & 2 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { mockCourseDiscoveryResponse } from './сatalog/__mocks__';
import { mockHomeSettingsResponse } from './home/__mocks__';
import messages from './сatalog/messages';
import { useHomeSettingsQuery } from './home/data/hooks';
import { useCourseDiscovery } from './сatalog/data/hooks';
import {
render, within, waitFor, screen,
Expand All @@ -16,10 +18,19 @@ jest.mock('@edx/frontend-platform', () => ({
})),
}));

jest.mock('./home/data/hooks', () => ({
useHomeSettingsQuery: jest.fn(),
}));

jest.mock('./сatalog/data/hooks', () => ({
useCourseDiscovery: jest.fn(),
}));

jest.mock('./header/hooks/useMenuItems', () => ({
useMenuItems: jest.fn(() => ([])),
}));

const mockHomeSettings = useHomeSettingsQuery as jest.Mock;
const mockCourseDiscovery = useCourseDiscovery as jest.Mock;

jest.mock('@edx/frontend-platform/react', () => ({
Expand All @@ -39,17 +50,28 @@ describe('App', () => {
document.body.innerHTML = '';
});

mockHomeSettings.mockReturnValue({
data: mockHomeSettingsResponse,
isLoading: false,
isError: false,
});

mockCourseDiscovery.mockReturnValue({
data: mockCourseDiscoveryResponse,
isLoading: false,
isError: false,
});

it('renders HomePage on "/" route', () => {
it('renders HomePage on "/" route', async () => {
window.testHistory = [ROUTES.HOME];

render(<App />);
expect(screen.getByTestId('home-page')).toBeInTheDocument();

await waitFor(() => {
expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
});

expect(screen.getByTestId('home-banner')).toBeInTheDocument();
});

it('renders CatalogPage with course cards at /courses route', async () => {
Expand Down
19 changes: 8 additions & 11 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { AppProvider } from '@edx/frontend-platform/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Route, Routes } from 'react-router-dom';
import { Container } from '@openedx/paragon';
import { FooterSlot } from '@edx/frontend-component-footer';
import Header from '@edx/frontend-component-header';
import CatalogHeader from './header/CatalogHeader';

import HomePage from './home/HomePage';
import CatalogPage from './сatalog/CatalogPage';
Expand All @@ -16,16 +15,14 @@ const queryClient = new QueryClient();
const App = () => (
<AppProvider>
<QueryClientProvider client={queryClient}>
<Header />
<CatalogHeader />
<main className="d-flex flex-column flex-grow-1">
<Container className="container-xl">
<Routes>
<Route path={ROUTES.HOME} element={<HomePage />} />
<Route path={ROUTES.COURSES} element={<CatalogPage />} />
<Route path={ROUTES.COURSE_ABOUT} element={<CourseAboutPage />} />
<Route path={ROUTES.NOT_FOUND} element={<NotFoundPage />} />
</Routes>
</Container>
<Routes>
<Route path={ROUTES.HOME} element={<HomePage />} />
<Route path={ROUTES.COURSES} element={<CatalogPage />} />
<Route path={ROUTES.COURSE_ABOUT} element={<CourseAboutPage />} />
<Route path={ROUTES.NOT_FOUND} element={<NotFoundPage />} />
</Routes>
</main>
<FooterSlot />
</QueryClientProvider>
Expand Down
Binary file added src/assets/images/home-banner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
File renamed without changes
File renamed without changes
23 changes: 23 additions & 0 deletions src/assets/scss/_animations.scss
Original file line number Diff line number Diff line change
@@ -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);
}
}
12 changes: 12 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const configuration = {
LMS_BASE_URL: process.env.LMS_BASE_URL,
SITE_NAME: process.env.SITE_NAME,
// SUPPORT_URL: process.env.SUPPORT_URL || null,
INFO_EMAIL: process.env.INFO_EMAIL || '',
LOGO_URL: process.env.LOGO_URL,
ENABLE_PROGRAMS: process.env.ENABLE_PROGRAMS === 'true',
};

const features = {};

export { configuration, features };
15 changes: 15 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 2 additions & 1 deletion src/generic/course-card/CourseCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getConfig } from '@edx/frontend-platform';

import { mockCourseResponse } from '../../__mocks__';
import { render, screen } from '../../setupTest';
import { ROUTES } from '../../routes';
import { CourseCard } from '.';

import messages from './messages';
Expand Down Expand Up @@ -37,7 +38,7 @@ describe('CourseCard', () => {
renderComponent();

const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', `/courses/${mockCourseResponse.id}/about`);
expect(link).toHaveAttribute('href', ROUTES.COURSE_ABOUT.replace(':courseId', mockCourseResponse.id));
});

it('handles missing start date gracefully', () => {
Expand Down
9 changes: 5 additions & 4 deletions src/generic/course-card/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import { Link } from 'react-router-dom';
import { Card, useMediaQuery, breakpoints } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';

import { ROUTES } from '../../routes';
import { CourseCardProps } from './types';
import messages from './messages';
import { getFullImageUrl } from './utils';
import { DATE_FORMAT_OPTIONS } from './constants';
import messages from './messages';

import noCourseImg from '../../assets/no-course-image.svg';
import noOrgImg from '../../assets/no-org-image.svg';
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
Expand All @@ -23,7 +24,7 @@ export const CourseCard = ({ course }: CourseCardProps) => {
return (
<Card
as={Link}
to={`/courses/${course.id}/about`}
to={ROUTES.COURSE_ABOUT.replace(':courseId', course.id)}
className={`course-card ${isExtraSmall ? 'w-100' : 'course-card-desktop'}`}
isClickable
>
Expand Down
2 changes: 1 addition & 1 deletion src/generic/course-card/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const messages = defineMessages({
startDate: {
id: 'generic.course-card.start-date',
defaultMessage: 'Starts: {startDate}',
description: 'Start date',
description: 'Start date.',
},
});

Expand Down
1 change: 1 addition & 0 deletions src/generic/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ 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';
47 changes: 47 additions & 0 deletions src/generic/video-modal/VideoModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { render, userEvent, cleanup } from '../../setupTest';
import messages from './messages';
import { VideoModal } from '.';

const videoModalProps = {
isOpen: true,
close: jest.fn(),
videoID: 'some_id',
};

describe('<VideoModal />', () => {
afterEach(() => {
cleanup();
jest.clearAllMocks();
});

it('renders modal with correct title and iframe when open', () => {
const { getByTitle } = render(<VideoModal {...videoModalProps} />);
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(<VideoModal {...videoModalProps} isOpen={false} />);

expect(queryByTitle(messages.videoIframeTitle.defaultMessage)).not.toBeInTheDocument();
});

it('calls close function when modal backdrop is clicked or esc is pressed (if supported)', async () => {
render(<VideoModal {...videoModalProps} />);

await userEvent.keyboard('{Escape}');
expect(videoModalProps.close).toHaveBeenCalledTimes(1);
});

it('renders iframe with correct attributes', () => {
const { getByTitle } = render(<VideoModal {...videoModalProps} />);
const iframe = getByTitle(messages.videoIframeTitle.defaultMessage);

expect(iframe).toHaveAttribute('width', 'auto');
expect(iframe).toHaveAttribute('height', '500');
expect(iframe).toHaveAttribute('frameBorder', '0');
expect(iframe).toHaveAttribute('allowFullScreen');
});
});
39 changes: 39 additions & 0 deletions src/generic/video-modal/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ModalDialog
title={intl.formatMessage(messages.videoModalTitle)}
size={size}
isOpen={isOpen}
onClose={close}
hasCloseButton={false}
isOverflowVisible={false}
className="bg-transparent shadow-none"
>
<iframe
title={intl.formatMessage(messages.videoIframeTitle)}
width={width}
height={height}
src={`//www.youtube.com/embed/${videoID}?showinfo=0`}
frameBorder="0"
allowFullScreen
allow={IFRAME_FEATURE_POLICY}
/>
</ModalDialog>
);
};
16 changes: 16 additions & 0 deletions src/generic/video-modal/messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { defineMessages } from '@edx/frontend-platform/i18n';

const messages = defineMessages({
videoModalTitle: {
id: 'catalog.home-page.video-modal-title',
defaultMessage: 'Video modal',
description: 'Title text displayed inside the modal when the promo video is opened.',
},
videoIframeTitle: {
id: 'catalog.home-page.video-button',
defaultMessage: 'YouTube Video title',
description: 'Title attribute for the embedded YouTube iframe (used for accessibility).',
},
});

export default messages;
8 changes: 8 additions & 0 deletions src/generic/video-modal/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface VideoModalProps {
isOpen: boolean;
close: () => void,
videoID: string,
size?: 'sm' | 'md' | 'lg',
width?: number | 'auto',
height?: number | 'auto',
}
Loading