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
4 changes: 2 additions & 2 deletions .env.development
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
2 changes: 1 addition & 1 deletion .env.test
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
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'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
ECOMMERCE_BASE_URL='http://localhost:18130'
Expand Down
4 changes: 2 additions & 2 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ module.exports = createConfig('jest', {
// setupFilesAfterEnv is used after the jest environment has been loaded. In general this is what you want.
// If you want to add config BEFORE jest loads, use setupFiles instead.
setupFilesAfterEnv: [
'<rootDir>/src/setupTest.js',
'<rootDir>/src/setupTest.tsx',
],
coveragePathIgnorePatterns: [
'src/setupTest.js',
'src/setupTest.tsx',
'src/i18n',
],
});
Expand Down
21,935 changes: 14,031 additions & 7,904 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "^14.7.1",
"@edx/frontend-component-header": "^6.4.0",
"@edx/frontend-platform": "^8.3.5",
"@edx/frontend-platform": "^8.4.0",
"@edx/openedx-atlas": "^0.6.0",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-brands-svg-icons": "5.15.4",
Expand All @@ -53,7 +53,7 @@
"regenerator-runtime": "0.14.1"
},
"devDependencies": {
"@edx/browserslist-config": "^1.1.1",
"@edx/browserslist-config": "^1.5.0",
"@edx/reactifex": "^2.1.1",
"@edx/stylelint-config-edx": "2.3.3",
"@edx/typescript-config": "^1.1.0",
Expand Down
51 changes: 44 additions & 7 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { render, screen } from './setupTest';
import { mockCourseDiscoveryResponse } from './сatalog/__mocks__';
import messages from './сatalog/messages';
import { useCourseDiscovery } from './сatalog/data/hooks';
import {
render, within, waitFor, screen,
} from './setupTest';
import { ROUTES } from './routes';
import App from './App';

Expand All @@ -11,12 +16,14 @@ jest.mock('@edx/frontend-platform', () => ({
})),
}));

jest.mock('@edx/frontend-platform/react', () => ({
AppProvider: ({ children }: { children: React.ReactNode }) => <div data-testid="app-provider">{children}</div>,
jest.mock('./сatalog/data/hooks', () => ({
useCourseDiscovery: jest.fn(),
}));

jest.mock('@openedx/paragon', () => ({
Container: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
const mockCourseDiscovery = useCourseDiscovery as jest.Mock;

jest.mock('@edx/frontend-platform/react', () => ({
AppProvider: ({ children }: { children: React.ReactNode }) => <div data-testid="app-provider">{children}</div>,
}));

jest.mock('@edx/frontend-component-header', () => function getHeader() {
Expand All @@ -32,18 +39,48 @@ describe('App', () => {
document.body.innerHTML = '';
});

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

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

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

it('renders CatalogPage on "/courses" route', () => {
it('renders CatalogPage with course cards at /courses route', async () => {
window.testHistory = [ROUTES.COURSES];

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

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"', () => {
Expand Down
20 changes: 20 additions & 0 deletions src/__mocks__/course.ts
Original file line number Diff line number Diff line change
@@ -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',
},
};
1 change: 1 addition & 0 deletions src/__mocks__/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { mockCourseResponse } from './course';
4 changes: 4 additions & 0 deletions src/assets/no-course-image.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions src/assets/no-org-image.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 26 additions & 0 deletions src/custom.d.ts
Original file line number Diff line number Diff line change
@@ -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;
}
39 changes: 39 additions & 0 deletions src/generic/alert-notification/AlertNotification.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { render, screen } from '../../setupTest';
import { AlertNotificationProps } from './types';
import { AlertNotification } from '.';

const renderComponent = (props: AlertNotificationProps) => render(<AlertNotification {...props} />);

describe('AlertNotification', () => {
it('renders with default props', () => {
renderComponent({
title: 'Test Title',
message: 'Test Message',
variant: 'info',
});

expect(screen.getByText('Test Title')).toBeInTheDocument();
expect(screen.getByText('Test Message')).toBeInTheDocument();
expect(screen.getByRole('alert')).toHaveClass('alert-info');
});

it('renders with custom variant', () => {
renderComponent({
title: 'Warning Title',
message: 'Warning Message',
variant: 'warning',
});

expect(screen.getByRole('alert')).toHaveClass('alert-warning');
});

it('displays the info icon', () => {
renderComponent({
title: 'Alert with Icon',
message: 'Has info icon',
variant: 'info',
});

expect(screen.getByRole('alert').querySelector('svg')).toBeInTheDocument();
});
});
13 changes: 13 additions & 0 deletions src/generic/alert-notification/index.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<BaseAlert variant={variant} icon={InfoIcon}>
<BaseAlert.Heading>{title}</BaseAlert.Heading>
<p>{message}</p>
</BaseAlert>
);
5 changes: 5 additions & 0 deletions src/generic/alert-notification/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface AlertNotificationProps {
variant?: 'info' | 'warning';
title: string;
message: string;
}
55 changes: 55 additions & 0 deletions src/generic/course-card/CourseCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { getConfig } from '@edx/frontend-platform';

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

import messages from './messages';

describe('CourseCard', () => {
const renderComponent = (course = mockCourseResponse) => render(
<CourseCard course={course} />,
);

it('renders course information correctly', () => {
renderComponent();

expect(screen.getByText(mockCourseResponse.data.content.displayName)).toBeInTheDocument();
expect(screen.getByText(mockCourseResponse.data.org)).toBeInTheDocument();
expect(screen.getByText('Starts: Apr 1, 2024')).toBeInTheDocument();
});
Copy link

@diana-villalvazo-wgu diana-villalvazo-wgu Jun 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: RTL generally recommend using the screen helper, it's shorter and helps if you also appreciate the shorter syntax and less need to pick which selectors to define, so instead of

const { getByText } = renderComponent();
expect( getByText(...) ).toBlah()

they encourage to do:

import { ..., screen } from '../setupTest';

renderComponent();
expect( screen.getByText(...) ).toBlah()

Again this is optional, not a requirement.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If RTL generally recommend, then I also support this idea 💯
Added screen for tests in this PR (other tests will be updated iteratively, when adding subsequent PRs)


it('renders course image with correct src and fallback', () => {
renderComponent();

const image = screen.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', () => {
renderComponent();

const logo = screen.getByAltText(mockCourseResponse.data.org);
expect(logo).toHaveAttribute('src', `${getConfig().LMS_BASE_URL}${mockCourseResponse.data.orgImg}`);
});

it('formats the link destination correctly', () => {
renderComponent();

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

it('handles missing start date gracefully', () => {
const courseWithoutStart = {
...mockCourseResponse,
data: {
...mockCourseResponse.data,
start: '',
},
};
renderComponent(courseWithoutStart);

expect(screen.queryByText(messages.startDate.defaultMessage.replace('{startDate}', courseWithoutStart.data.start))).not.toBeInTheDocument();
});
});
1 change: 1 addition & 0 deletions src/generic/course-card/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const DATE_FORMAT_OPTIONS = { month: 'short', day: 'numeric', year: 'numeric' } as const;
51 changes: 51 additions & 0 deletions src/generic/course-card/index.tsx
Original file line number Diff line number Diff line change
@@ -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/no-course-image.svg';
import noOrgImg from '../../assets/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 (
<Card
as={Link}
to={`/courses/${course.id}/about`}
className={`course-card ${isExtraSmall ? 'w-100' : 'course-card-desktop'}`}
isClickable
>
<Card.ImageCap
src={getFullImageUrl(course.data.imageUrl)}
fallbackSrc={noCourseImg}
srcAlt={course.data.content.displayName}
logoSrc={course.data.orgImg ? getFullImageUrl(course.data.orgImg) : undefined}
fallbackLogoSrc={!course.data.orgImg && noOrgImg}
logoAlt={course.data.org}
/>
<Card.Section>
<h3 className="m-0">{course.data.content.displayName}</h3>
<p className="m-0">{course.data.org}</p>
{formattedDate && (
<span>
{intl.formatMessage(messages.startDate, {
startDate: formattedDate,
})}
</span>
)}
</Card.Section>
</Card>
);
};
11 changes: 11 additions & 0 deletions src/generic/course-card/messages.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading