Skip to content
Merged
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
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ MFE_CONFIG_API_URL=''
PARAGON_THEME_URLS={}
HOMEPAGE_PROMO_VIDEO_YOUTUBE_ID=''
SUPPORT_URL=''
COURSE_ABOUT_TWITTER_ACCOUNT=''
1 change: 1 addition & 0 deletions .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ HOMEPAGE_PROMO_VIDEO_YOUTUBE_ID='test-youtube-id'
ENABLE_COURSE_DISCOVERY=true
ENABLE_PROGRAMS=true
SUPPORT_URL=''
COURSE_ABOUT_TWITTER_ACCOUNT=''
1 change: 1 addition & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ ENABLE_COURSE_DISCOVERY=true
ENABLE_PROGRAMS=true
[email protected]
SUPPORT_URL='https://support.example.com'
COURSE_ABOUT_TWITTER_ACCOUNT='@example'
8 changes: 7 additions & 1 deletion src/__mocks__/courseAbout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,12 @@ export const mockCourseAboutResponse = {
},
userHasPassingGrade: false,
courseExitPageIsActive: false,
certificateData: null,
certificateData: {
certStatus: 'none',
certWebViewUrl: null,
downloadUrl: null,
certificateAvailableDate: null,
},
verifyIdentityUrl: null,
verificationStatus: 'none',
linkedinAddToProfileUrl: null,
Expand Down Expand Up @@ -94,4 +99,5 @@ export const mockCourseAboutResponse = {
overview: '<div>Course overview content</div>',
ocwLinks: [],
prerequisites: [],
requirements: 'Basic programming knowledge',
};
257 changes: 255 additions & 2 deletions src/course-about/CourseAboutPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { useLocation } from 'react-router-dom';
import { useMediaQuery } from '@openedx/paragon';

import genericMessages from '../generic/video-modal/messages';
import {
render, waitFor, screen, userEvent,
render, waitFor, screen, userEvent, within,
} from '../setupTest';
import { mockCourseAboutResponse } from '../__mocks__';
import CourseAboutPage from './CourseAboutPage';
import { fetchCourseAboutData } from './data/api';
import messages from './course-intro/messages';
import courseMediaMessages from './course-intro/course-media/messages';
import sidebarDetailsMessages from './course-sidebar/sidebar-details/messages';
import sidebarSocialMessages from './course-sidebar/sidebar-social/messages';
import { ROUTES } from '../routes';

const mockGetAuthenticatedUser = jest.fn();

Expand All @@ -24,16 +28,24 @@ jest.mock('react-router-dom', () => ({
useLocation: jest.fn(),
}));

jest.mock('@openedx/paragon', () => ({
...jest.requireActual('@openedx/paragon'),
useMediaQuery: jest.fn(),
}));

const mockUseMediaQuery = useMediaQuery as jest.Mock;

const mockFetchCourseAboutData = fetchCourseAboutData as jest.Mock;
const mockUseLocation = useLocation as jest.Mock;

describe('CourseAboutPage Integration Tests', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseLocation.mockReturnValue({
pathname: '/catalog/course-v1:TestX+Test101+2023/about',
pathname: ROUTES.COURSE_ABOUT.replace(':courseId', 'course-v1:TestX+Test101+2023'),
});
mockGetAuthenticatedUser.mockReturnValue(null);
mockUseMediaQuery.mockReturnValue(false);
});

it('should show loading state when data is being fetched', async () => {
Expand Down Expand Up @@ -159,4 +171,245 @@ describe('CourseAboutPage Integration Tests', () => {
expect(screen.getByText(messages.statusMessageFull.defaultMessage)).toBeInTheDocument();
});
});

describe('Course sidebar', () => {
describe('Sidebar details', () => {
it('should render course sidebar with course details', async () => {
const courseData = {
...mockCourseAboutResponse,
displayNumberWithDefault: 'CS101',
effort: '6-8 hours per week',
requirements: 'Basic programming knowledge',
coursePrice: '$99',
};

mockFetchCourseAboutData.mockReturnValue(courseData);

render(<CourseAboutPage />);

await waitFor(() => {
const sidebar = screen.getByRole('complementary');
expect(within(sidebar).getByText(courseData.displayNumberWithDefault)).toBeInTheDocument();
expect(within(sidebar).getByText(courseData.effort)).toBeInTheDocument();
expect(within(sidebar).getByText(courseData.requirements)).toBeInTheDocument();
expect(within(sidebar).getByText(courseData.coursePrice)).toBeInTheDocument();
});
});

it('should display start date in sidebar when not default', async () => {
const courseData = {
...mockCourseAboutResponse,
startDateIsStillDefault: false,
start: '2024-03-15T00:00:00Z',
};

mockFetchCourseAboutData.mockReturnValue(courseData);

render(<CourseAboutPage />);

await waitFor(() => {
const sidebar = screen.getByRole('complementary');
expect(within(sidebar).getByText('Mar 15, 2024')).toBeInTheDocument();
});
});

it('should display end date in sidebar when available', async () => {
const courseData = {
...mockCourseAboutResponse,
end: '2024-06-15T00:00:00Z',
};

mockFetchCourseAboutData.mockReturnValue(courseData);

render(<CourseAboutPage />);

await waitFor(() => {
const sidebar = screen.getByRole('complementary');
expect(within(sidebar).getByText('Jun 15, 2024')).toBeInTheDocument();
});
});

it('should not display effort when not provided', async () => {
const courseData = {
...mockCourseAboutResponse,
effort: null,
};

mockFetchCourseAboutData.mockReturnValue(courseData);

render(<CourseAboutPage />);

await waitFor(() => {
const sidebar = screen.getByRole('complementary');
expect(
within(sidebar).queryByText(sidebarDetailsMessages.estimatedEffort.defaultMessage),
).not.toBeInTheDocument();
});
});

it('should not display requirements when not provided', async () => {
const courseData = {
...mockCourseAboutResponse,
requirements: null,
};

mockFetchCourseAboutData.mockReturnValue(courseData);

render(<CourseAboutPage />);

await waitFor(() => {
const sidebar = screen.getByRole('complementary');
expect(
within(sidebar).queryByText(sidebarDetailsMessages.requirements.defaultMessage),
).not.toBeInTheDocument();
});
});
});

describe('Sidebar social', () => {
it('should display social sharing options in sidebar', async () => {
mockFetchCourseAboutData.mockReturnValue(mockCourseAboutResponse);

render(<CourseAboutPage />);

await waitFor(() => {
const sidebar = screen.getByRole('complementary');

expect(within(sidebar).getByText(
sidebarSocialMessages.socialSharingTwitter.defaultMessage,
)).toBeInTheDocument();
expect(within(sidebar).getByText(
sidebarSocialMessages.socialSharingFacebook.defaultMessage,
)).toBeInTheDocument();
expect(within(sidebar).getByText(
sidebarSocialMessages.socialSharingEmail.defaultMessage,
)).toBeInTheDocument();
});
});

it('should have correct Twitter share URL in sidebar', async () => {
const courseData = {
...mockCourseAboutResponse,
displayNumberWithDefault: 'CS101',
name: 'Test Course',
};

mockFetchCourseAboutData.mockReturnValue(courseData);

render(<CourseAboutPage />);

await waitFor(() => {
const sidebar = screen.getByRole('complementary');
const twitterLink = within(sidebar).getByText(
sidebarSocialMessages.socialSharingTwitter.defaultMessage,
).closest('a');

expect(twitterLink).toHaveAttribute('href', expect.stringContaining('twitter.com/intent/tweet'));
expect(twitterLink?.getAttribute('href')).toContain(encodeURIComponent(courseData.displayNumberWithDefault));
expect(twitterLink?.getAttribute('href')).toContain(encodeURIComponent(courseData.name));
});
});

it('should have correct Facebook share URL in sidebar', async () => {
mockFetchCourseAboutData.mockReturnValue(mockCourseAboutResponse);

render(<CourseAboutPage />);

await waitFor(() => {
const sidebar = screen.getByRole('complementary');
const facebookLink = within(sidebar).getByText(
sidebarSocialMessages.socialSharingFacebook.defaultMessage,
).closest('a');

expect(facebookLink).toHaveAttribute('href', expect.stringContaining('facebook.com/sharer/sharer.php'));
});
});

it('should have correct Email share URL in sidebar', async () => {
const courseData = {
...mockCourseAboutResponse,
displayNumberWithDefault: 'MATH201',
name: 'Advanced Mathematics',
};

mockFetchCourseAboutData.mockReturnValue(courseData);

render(<CourseAboutPage />);

await waitFor(() => {
const sidebar = screen.getByRole('complementary');
const emailLink = within(sidebar).getByText(
sidebarSocialMessages.socialSharingEmail.defaultMessage,
).closest('a');

expect(emailLink).toHaveAttribute('href', expect.stringContaining('mailto:'));
expect(emailLink?.getAttribute('href')).toContain(encodeURIComponent(courseData.displayNumberWithDefault));
expect(emailLink?.getAttribute('href')).toContain(encodeURIComponent(courseData.name));
});
});
});
});

describe('Responsive layout', () => {
beforeEach(() => {
mockFetchCourseAboutData.mockReturnValue(mockCourseAboutResponse);
});

it('should render mobile layout for small screens', async () => {
mockUseMediaQuery.mockReturnValue(true);

render(<CourseAboutPage />);

await waitFor(() => {
expect(screen.getByText(mockCourseAboutResponse.name)).toBeInTheDocument();
expect(screen.getByText(mockCourseAboutResponse.displayOrgWithDefault)).toBeInTheDocument();

const sidebar = screen.getByRole('complementary');
expect(sidebar).toBeInTheDocument();

expect(screen.getByAltText(mockCourseAboutResponse.name)).toBeInTheDocument();
});
});

it('should render desktop layout for large screens', async () => {
mockUseMediaQuery.mockReturnValue(false);

render(<CourseAboutPage />);

await waitFor(() => {
expect(screen.getByText(mockCourseAboutResponse.name)).toBeInTheDocument();
expect(screen.getByText(mockCourseAboutResponse.displayOrgWithDefault)).toBeInTheDocument();

const sidebar = screen.getByRole('complementary');
expect(sidebar).toBeInTheDocument();

expect(screen.getByAltText(mockCourseAboutResponse.name)).toBeInTheDocument();
});
});

it('should apply correct CSS classes for mobile layout', async () => {
mockUseMediaQuery.mockReturnValue(true);

render(<CourseAboutPage />);

await waitFor(() => {
const mediaWrapper = document.querySelector('.course-media-wrapper.text-center');
expect(mediaWrapper).toBeInTheDocument();
});
});

it('should apply correct CSS classes for desktop layout', async () => {
mockUseMediaQuery.mockReturnValue(false);

render(<CourseAboutPage />);

await waitFor(() => {
const mediaWrapper = document.querySelector('.course-media-wrapper.text-center');
expect(mediaWrapper).not.toBeInTheDocument();

const mediaWrapperWithoutCenter = document.querySelector('.course-media-wrapper:not(.text-center)');
expect(mediaWrapperWithoutCenter).toBeInTheDocument();
});
});
});
});
46 changes: 36 additions & 10 deletions src/course-about/CourseAboutPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useLocation } from 'react-router';
import { Container, Layout, Alert } from '@openedx/paragon';
import {
Container, Layout, Alert, useMediaQuery, breakpoints, Stack,
} from '@openedx/paragon';
import { ErrorPage } from '@edx/frontend-platform/react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
Expand All @@ -8,16 +10,15 @@ import { Loading } from '@src/generic';
import CourseAboutIntroSlot from '@src/plugin-slots/CourseAboutIntroSlot';
import CourseAboutCourseMediaSlot from '@src/plugin-slots/CourseAboutCourseMediaSlot';
import { useCourseAboutData } from './data/hooks';
import CourseSidebar from './course-sidebar/CourseSidebar';
import { CourseOverview } from './course-overview';
import messages from './messages';

export const GRID_LAYOUT = {
xs: [{ span: 12 }, { span: 'auto' }],
xl: [{ span: 9 }, { span: 3 }],
};
import { GRID_LAYOUT } from './layout';

const CourseAboutPage = () => {
const intl = useIntl();
const courseId = useLocation().pathname.split('/')[2];
const isSmallScreen = useMediaQuery({ maxWidth: breakpoints.large.maxWidth });
const {
data: courseAboutData,
isLoading,
Expand All @@ -43,13 +44,38 @@ const CourseAboutPage = () => {
}

return (
<Container fluid={false} size="xl" className="course-about-intro-wrapper py-5.5">
<Container fluid={false} size="xl" className="py-5.5">
<Layout {...GRID_LAYOUT}>
<Layout.Element>
<CourseAboutIntroSlot courseAboutData={courseAboutData} />
{isSmallScreen ? (
<Stack gap={4}>
<Layout.Element className="course-media-wrapper text-center">
<CourseAboutCourseMediaSlot courseAboutData={courseAboutData} />
</Layout.Element>
<CourseAboutIntroSlot courseAboutData={courseAboutData} />
<CourseOverview />
<aside>
<CourseSidebar courseAboutData={courseAboutData} />
</aside>
</Stack>
) : (
<Stack gap={4}>
<CourseAboutIntroSlot courseAboutData={courseAboutData} />
<CourseOverview />
</Stack>
)}
</Layout.Element>
<Layout.Element className="course-media-wrapper">
<CourseAboutCourseMediaSlot courseAboutData={courseAboutData} />
<Layout.Element>
{!isSmallScreen && (
<Stack gap={4}>
<Layout.Element className="course-media-wrapper">
<CourseAboutCourseMediaSlot courseAboutData={courseAboutData} />
</Layout.Element>
<aside>
<CourseSidebar courseAboutData={courseAboutData} />
</aside>
</Stack>
)}
</Layout.Element>
</Layout>
</Container>
Expand Down
Loading