Skip to content
Open
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.development
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ ENABLE_COURSE_DISCOVERY=true
ENABLE_PROGRAMS=true
SUPPORT_URL=''
COURSE_ABOUT_TWITTER_ACCOUNT=''
ENABLE_PATHWAY_PILOT_UI=false
1 change: 1 addition & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ PARAGON_THEME_URLS={}
HOMEPAGE_PROMO_VIDEO_YOUTUBE_ID='test-youtube-id'
ENABLE_COURSE_DISCOVERY=true
ENABLE_PROGRAMS=true
ENABLE_PATHWAY_PILOT_UI=false
INFO_EMAIL=support@example.com
SUPPORT_URL='https://support.example.com'
COURSE_ABOUT_TWITTER_ACCOUNT='@example'
14 changes: 14 additions & 0 deletions src/generic/cards.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
.catalog-card-badge {
top: 1rem;
inset-inline-start: 1rem;
}

.course-card-badge {
background-color: var(--catalog-course-badge-background-color);
color: var(--catalog-course-badge-color);
}

.pathway-card-badge {
background-color: var(--pgn-color-info-200);
color: var(--pgn-color-info-900);
}
28 changes: 28 additions & 0 deletions src/generic/course-card/CourseCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,22 @@ import { CourseCard } from '.';

import messages from './messages';

jest.mock('@edx/frontend-platform', () => {
const actual = jest.requireActual('@edx/frontend-platform');
return {
...actual,
getConfig: jest.fn(actual.getConfig),
};
});

const defaultConfig = getConfig();
const mockGetConfig = getConfig as jest.Mock;

describe('CourseCard', () => {
beforeEach(() => {
mockGetConfig.mockReturnValue({ ...defaultConfig, ENABLE_PATHWAY_PILOT_UI: true });
});

const renderComponent = (course = mockCourseResponse) => render(
<CourseCard
courseId={course.id}
Expand All @@ -26,6 +41,18 @@ describe('CourseCard', () => {
expect(screen.getByText(mockCourseResponse.data.content.displayName)).toBeInTheDocument();
expect(screen.getByText(mockCourseResponse.data.org)).toBeInTheDocument();
expect(screen.getByText(mockCourseResponse.data.number)).toBeInTheDocument();
expect(screen.getByText(messages.course.defaultMessage)).toHaveClass(
'catalog-card-badge',
'course-card-badge',
);
});

it('does not render the badge when disabled', () => {
mockGetConfig.mockReturnValue({ ...defaultConfig, ENABLE_PATHWAY_PILOT_UI: false });

renderComponent();

expect(screen.queryByText(messages.course.defaultMessage)).not.toBeInTheDocument();
});

it('displays advertisedStart when available', () => {
Expand Down Expand Up @@ -153,6 +180,7 @@ describe('CourseCard', () => {
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();
expect(screen.queryByText(messages.course.defaultMessage)).not.toBeInTheDocument();
});

it('does not display start date when loading', () => {
Expand Down
8 changes: 8 additions & 0 deletions src/generic/course-card/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Link } from 'react-router-dom';
import {
Card, useMediaQuery, breakpoints, Badge,
} from '@openedx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';

import noCourseImg from '@src/assets/images/no-course-image.svg';
Expand Down Expand Up @@ -45,6 +46,13 @@ export const CourseCard = ({
srcAlt={`${courseName} ${courseNumber}`}
skeletonDuringImageLoad
/>
{!isLoading && getConfig().ENABLE_PATHWAY_PILOT_UI && (
<Badge
className="catalog-card-badge course-card-badge position-absolute py-1 px-2"
>
{intl.formatMessage(messages.course)}
</Badge>
)}
<Card.Header
title={courseName}
subtitle={(
Expand Down
5 changes: 5 additions & 0 deletions src/generic/course-card/messages.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';

const messages = defineMessages({
course: {
id: 'generic.course-card.course',
defaultMessage: 'Course',
description: 'Course badge label.',
},
startDate: {
id: 'generic.course-card.start-date',
defaultMessage: 'Starts: {startDate}',
Expand Down
1 change: 1 addition & 0 deletions src/generic/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { CourseCard } from './course-card';
export { PathwayCard } from './pathway-card';
export { SubHeader } from './sub-header';
export { LoadingSpinner, Loading } from './loading-spinner';
export { AlertNotification } from './alert-notification';
Expand Down
94 changes: 94 additions & 0 deletions src/generic/pathway-card/PathwayCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { getConfig } from '@edx/frontend-platform';
import { render, screen, formatDateForTest } from '@src/setupTest';

import { PathwayCard } from '.';
import messages from './messages';

jest.mock('@edx/frontend-platform', () => ({
...jest.requireActual('@edx/frontend-platform'),
getConfig: jest.fn(),
}));

const mockGetConfig = getConfig as jest.Mock;

describe('PathwayCard', () => {
beforeEach(() => {
mockGetConfig.mockReturnValue({ ENABLE_PATHWAY_PILOT_UI: true });
});

const props = {
pathwayId: 'pathway-1',
name: 'Web Development',
org: 'Open edX',
courseCount: 2,
imageUrl: '/pathway.jpg',
startDate: '2024-04-01T00:00:00Z',
};

it('renders pathway information and start date', () => {
render(<PathwayCard {...props} />);

expect(screen.getByText(props.name)).toBeInTheDocument();
expect(screen.getByText(props.org)).toBeInTheDocument();
expect(screen.getByText('2 Courses')).toBeInTheDocument();
expect(screen.getByText(
messages.startDate.defaultMessage.replace('{startDate}', formatDateForTest(props.startDate)),
)).toBeInTheDocument();
});

it('renders the pathway type badge with custom colors', () => {
render(<PathwayCard {...props} type="Bootcamp" typeBackgroundColor="#123456" typeTextColor="white" />);

expect(screen.getByText('Bootcamp')).toHaveStyle({
backgroundColor: '#123456',
color: 'white',
});
});

it('does not render the badge when disabled', () => {
mockGetConfig.mockReturnValue({ ENABLE_PATHWAY_PILOT_UI: false });

render(<PathwayCard {...props} type="Bootcamp" />);

expect(screen.queryByText('Bootcamp')).not.toBeInTheDocument();
});

it('uses default badge styling when either custom color is invalid or missing', () => {
const { rerender } = render(
<PathwayCard {...props} type="Bootcamp" typeBackgroundColor="#123456" />,
);
expect(screen.getByText('Bootcamp').style.backgroundColor).toBe('');

rerender(<PathwayCard {...props} type="Bootcamp" typeBackgroundColor="not-a-color" typeTextColor="white" />);
expect(screen.getByText('Bootcamp').style.backgroundColor).toBe('');
});

it('does not render a pathway type badge when type is missing', () => {
render(<PathwayCard {...props} />);

expect(screen.queryByText('Bootcamp')).not.toBeInTheDocument();
});

it('uses the singular course label and links to the pathway', () => {
render(<PathwayCard {...props} courseCount={1} />);

expect(screen.getByText('1 Course')).toBeInTheDocument();
expect(screen.getByRole('link')).toHaveAttribute('href', '/pathways/pathway-1');
});

it('renders as a div without a pathway id', () => {
render(<PathwayCard {...props} pathwayId={undefined} />);

expect(screen.queryByRole('link')).not.toBeInTheDocument();
expect(screen.getByTestId('pathway-card').tagName).toBe('DIV');
});

it('renders skeletons while loading', () => {
render(<PathwayCard isLoading />);

expect(document.querySelectorAll('.react-loading-skeleton')).toHaveLength(4);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
expect(screen.queryByTestId('pathway-card')).toBeInTheDocument();
expect(screen.queryByText('Bootcamp')).not.toBeInTheDocument();
});
});
92 changes: 92 additions & 0 deletions src/generic/pathway-card/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { getConfig } from '@edx/frontend-platform';
import { Link } from 'react-router-dom';
import {
Badge, Card, breakpoints, useMediaQuery,
} from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';

import noCourseImg from '@src/assets/images/no-course-image.svg';
import { isValidCssColor } from '@src/utils';

import messages from './messages';
import type { PathwayCardProps } from './types';
import { getFullImageUrl, getStartDateDisplay } from '../course-card/utils';

export const PathwayCard = ({
isLoading,
pathwayId,
name,
org,
courseCount,
imageUrl,
startDate,
advertisedStart,
type,
typeBackgroundColor,
typeTextColor,
}: PathwayCardProps) => {
const intl = useIntl();
const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.small.maxWidth });
const startDateDisplay = (startDate || advertisedStart)
? getStartDateDisplay({
start: startDate,
advertisedStart,
}, intl)
: null;

const hasCustomColors = !!typeBackgroundColor
&& !!typeTextColor
&& isValidCssColor(typeBackgroundColor)
&& isValidCssColor(typeTextColor);

return (
<Card
as={pathwayId ? Link : 'div'}
to={pathwayId ? `/pathways/${pathwayId}` : undefined}
// TODO: Temporary use of `d-flex` to fix alignment. Remove once the related Paragon issue
// (https://github.com/openedx/paragon/issues/3792) is resolved.
className={`pathway-card d-flex ${isExtraSmall ? 'w-100' : 'pathway-card-desktop'}`}
isClickable={!isLoading}
isLoading={isLoading}
data-testid="pathway-card"
>
<Card.ImageCap
src={getFullImageUrl(imageUrl)}
fallbackSrc={noCourseImg}
srcAlt={name}
skeletonDuringImageLoad
/>
{!isLoading && getConfig().ENABLE_PATHWAY_PILOT_UI && type?.trim() && (
<Badge
className="catalog-card-badge pathway-card-badge position-absolute py-1 px-2"
style={hasCustomColors ? {
backgroundColor: typeBackgroundColor,
color: typeTextColor,
} : undefined}
>
{type}
</Badge>
)}
<Card.Header
title={name}
subtitle={(
<>
<div>
{courseCount !== undefined && intl.formatMessage(messages.courseCount, {
count: courseCount,
})}
</div>
<Badge variant="light">{org}</Badge>
</>
)}
size="sm"
/>
<Card.Section />
<Card.Footer
textElement={startDateDisplay && intl.formatMessage(messages.startDate, {
startDate: startDateDisplay,
})}
/>
</Card>
);
};
16 changes: 16 additions & 0 deletions src/generic/pathway-card/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({
courseCount: {
id: 'generic.pathway-card.course-count',
defaultMessage: '{count, plural, one {# Course} other {# Courses}}',
description: 'Number of courses in a pathway.',
},
startDate: {
id: 'generic.pathway-card.start-date',
defaultMessage: 'Starts: {startDate}',
description: 'Start date.',
},
});

export default messages;
36 changes: 36 additions & 0 deletions src/generic/pathway-card/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export interface PathwayCardProps {
isLoading?: boolean;
pathwayId?: string;
name?: string;
org?: string;
courseCount?: number;
imageUrl?: string;
startDate?: string;
advertisedStart?: string;
type?: string;
typeBackgroundColor?: string;
typeTextColor?: string;
}

export interface PathwayContent {
displayName: string;
}

export interface PathwayData {
org: string;
courseCount: number;
imageUrl?: string;
start?: string;
advertisedStart?: string;
type?: string;
typeBackgroundColor?: string;
typeTextColor?: string;
content: PathwayContent;
}

export interface Pathway {
id: string;
index?: string;
type?: string;
data: PathwayData;
}
6 changes: 6 additions & 0 deletions src/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,11 @@
@import "~@edx/frontend-component-header/dist/index";
@import "~@edx/frontend-component-footer/dist/footer";
@import "./assets/scss/animations";
@import "./generic/cards";
@import "./home";
@import "./course-about/CourseAboutPage";

:root {
--catalog-course-badge-background-color: #DBD0FE;
--catalog-course-badge-color: #411E65;
}
3 changes: 3 additions & 0 deletions src/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ describe('Index', () => {

expect(initialize).toHaveBeenCalledWith({
messages: expect.any(Object),
handlers: {
config: expect.any(Function),
},
});
});
});
Loading