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
2 changes: 1 addition & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,4 @@ ENABLE_PROGRAMS=false
NON_BROWSABLE_COURSES=false
SHOW_UNENROLL_SURVEY=true
# Fallback in local style files
PARAGON_THEME_URLS={}
PARAGON_THEME_URLS={}
2 changes: 1 addition & 1 deletion .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,4 @@ ENABLE_PROGRAMS=false
NON_BROWSABLE_COURSES=false
SHOW_UNENROLL_SURVEY=true
# Fallback in local style files
PARAGON_THEME_URLS={}
PARAGON_THEME_URLS={}
2 changes: 1 addition & 1 deletion src/App.scss
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ $input-focus-box-shadow: var(--pgn-elevation-form-input-base); // hack to get up
display: block;
box-sizing: content-box;
position: relative;
height: 37px;
height: 28px;
margin-right: 1rem;
img {
display: block;
Expand Down
39 changes: 34 additions & 5 deletions src/containers/CourseCard/CourseCard.scss
Original file line number Diff line number Diff line change
@@ -1,31 +1,60 @@
.course-card {
height: 100%;

.card {
height: 100%;

.pgn__card-wrapper-image-cap.vertical {
display: flex;
position: relative;
min-height: var(--pgn-size-card-image-vertical-max-height);
}
.pgn__card-image-cap {
border-bottom-left-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.overflow-visible {
overflow: visible;
}

.course-card-status-badge {
position: absolute;
top: 0.75rem;
right: 0.75rem;
z-index: 1000;
padding: 0.3rem 0.85rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
color: var(--pgn-color-white, #fff);

&.overdue {
background-color: #d9662e;
}

&.due-soon {
background-color: #e0942e;
}
}
.pgn__card-header-content {
margin-top: 1.5rem;
}
.pgn__card-footer {
flex-wrap: nowrap;

&.vertical {
flex-direction: column;
}
justify-content: space-between;

.pgn__action-row {
align-self: flex-end;
flex: 1;
white-space: nowrap;
}
}

.course-card-cta-button {
font-weight: 700;
padding-top: 0.65rem;
padding-bottom: 0.65rem;
}

.course-card-verify-ribbon-container {
width: 100%;
position: absolute;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
import PropTypes from 'prop-types';

import { useIntl } from '@edx/frontend-platform/i18n';
import { PlayArrow } from '@openedx/paragon/icons';
import { EXECUTIVE_EDUCATION_COURSE_MODES } from 'data/constants/course';

import track from 'tracking';
Expand Down Expand Up @@ -34,6 +35,9 @@ export const BeginCourseButton = ({ cardId }) => {
as="a"
href="#"
onClick={handleClick}
variant="success"
iconBefore={PlayArrow}
className="course-card-cta-button w-100"
>
{formatMessage(messages.beginCourse)}
</ActionButton>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,22 +63,22 @@ describe('BeginCourseButton', () => {
it('should be disabled', () => {
useActionDisabledState.mockReturnValueOnce({ disableBeginCourse: true });
renderComponent();
const button = screen.getByRole('button', { name: 'Begin Course' });
const button = screen.getByRole('button', { name: 'Enroll and Start' });
expect(button).toHaveClass('disabled');
expect(button).toHaveAttribute('aria-disabled', 'true');
});
});
describe('enabled', () => {
it('should be enabled', () => {
renderComponent();
const button = screen.getByRole('button', { name: 'Begin Course' });
const button = screen.getByRole('button', { name: 'Enroll and Start' });
expect(button).not.toHaveClass('disabled');
expect(button).not.toHaveAttribute('aria-disabled', 'true');
});
it('should track enter course clicked event on click, with exec ed param', () => {
renderComponent();
const user = userEvent.setup();
const button = screen.getByRole('button', { name: 'Begin Course' });
const button = screen.getByRole('button', { name: 'Enroll and Start' });
user.click(button);
expect(useCourseTrackingEvent).toHaveBeenCalledWith(
track.course.enterCourseClicked,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
import PropTypes from 'prop-types';

import { useIntl } from '@edx/frontend-platform/i18n';
import { PlayArrow } from '@openedx/paragon/icons';

import { EXECUTIVE_EDUCATION_COURSE_MODES } from 'data/constants/course';
import { baseAppUrl } from 'data/services/lms/urls';
Expand Down Expand Up @@ -35,6 +36,9 @@ export const ResumeButton = ({ cardId }) => {
as="a"
href="#"
onClick={handleClick}
variant="success"
iconBefore={PlayArrow}
className="course-card-cta-button w-100"
>
{formatMessage(messages.resume)}
</ActionButton>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const messages = defineMessages({
beginCourse: {
id: 'learner-dash.courseCard.actions.beginCourse',
description: 'Course card begin-course button text',
defaultMessage: 'Begin Course',
defaultMessage: 'Enroll and Start',
},
resume: {
id: 'learner-dash.courseCard.actions.resume',
Expand Down
13 changes: 13 additions & 0 deletions src/containers/CourseCard/components/CourseCardImage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import track from 'tracking';
import { useCourseData, useCourseTrackingEvent } from 'hooks';
import verifiedRibbon from 'assets/verified-ribbon.png';
import useActionDisabledState from './hooks';
import useCourseCardMeta from './CourseCardMeta/hooks';
import metaMessages from './CourseCardMeta/messages';

import messages from '../messages';

Expand All @@ -19,6 +21,7 @@ export const CourseCardImage = ({ cardId, orientation }) => {
const courseData = useCourseData(cardId);
const { homeUrl } = courseData?.courseRun || {};
const { disableCourseTitle } = useActionDisabledState(cardId);
const { isOverdue, isDueSoon } = useCourseCardMeta(cardId);
const handleImageClicked = useCourseTrackingEvent(courseImageClicked, cardId, homeUrl);
const wrapperClassName = `pgn__card-wrapper-image-cap d-inline-block overflow-visible ${orientation}`;
const image = (
Expand All @@ -30,6 +33,16 @@ export const CourseCardImage = ({ cardId, orientation }) => {
src={courseData?.course?.bannerImgSrc && baseAppUrl(courseData.course.bannerImgSrc)}
alt={formatMessage(messages.bannerAlt)}
/>
{
(isOverdue || isDueSoon) && (
<span
className={`course-card-status-badge ${isOverdue ? 'overdue' : 'due-soon'}`}
data-testid="CourseCardStatusBadge"
>
{formatMessage(isOverdue ? metaMessages.overdueBadge : metaMessages.dueSoonBadge)}
</span>
)
}
{
courseData?.enrollment?.isVerified && (
<span
Expand Down
35 changes: 35 additions & 0 deletions src/containers/CourseCard/components/CourseCardImage.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { IntlProvider } from '@edx/frontend-platform/i18n';
import { formatMessage } from 'testUtils';
import { useCourseData } from 'hooks';
import useActionDisabledState from './hooks';
import useCourseCardMeta from './CourseCardMeta/hooks';
import { CourseCardImage } from './CourseCardImage';
import messages from '../messages';

Expand All @@ -18,9 +19,13 @@ jest.mock('hooks', () => ({
useCourseTrackingEvent: jest.fn((eventName, cardId, url) => ({
trackCourseEvent: { eventName, cardId, url },
})),
utilHooks: {
useFormatDate: jest.fn(() => (date) => String(date)),
},
}));

jest.mock('./hooks', () => jest.fn());
jest.mock('./CourseCardMeta/hooks', () => jest.fn(() => ({ isOverdue: false, isDueSoon: false })));

describe('CourseCardImage', () => {
const props = {
Expand Down Expand Up @@ -89,6 +94,36 @@ describe('CourseCardImage', () => {
render(<IntlProvider locale="en"><CourseCardImage {...props} /></IntlProvider>);
expect(useCourseData).toHaveBeenCalledWith(props.cardId);
expect(useActionDisabledState).toHaveBeenCalledWith(props.cardId);
expect(useCourseCardMeta).toHaveBeenCalledWith(props.cardId);
});
});

describe('overdue/due-soon badge', () => {
beforeEach(() => {
useActionDisabledState.mockReturnValue({ disableCourseTitle: true });
useCourseData.mockReturnValue({
course: { bannerImgSrc },
courseRun: { homeUrl },
enrollment: {},
});
});

it('renders an Overdue badge when useCourseCardMeta reports isOverdue', () => {
useCourseCardMeta.mockReturnValue({ isOverdue: true, isDueSoon: false });
render(<IntlProvider locale="en"><CourseCardImage {...props} /></IntlProvider>);
expect(screen.getByTestId('CourseCardStatusBadge')).toHaveTextContent('Overdue');
});

it('renders a Due Soon badge when useCourseCardMeta reports isDueSoon', () => {
useCourseCardMeta.mockReturnValue({ isOverdue: false, isDueSoon: true });
render(<IntlProvider locale="en"><CourseCardImage {...props} /></IntlProvider>);
expect(screen.getByTestId('CourseCardStatusBadge')).toHaveTextContent('Due Soon');
});

it('renders no badge when neither overdue nor due soon', () => {
useCourseCardMeta.mockReturnValue({ isOverdue: false, isDueSoon: false });
render(<IntlProvider locale="en"><CourseCardImage {...props} /></IntlProvider>);
expect(screen.queryByTestId('CourseCardStatusBadge')).not.toBeInTheDocument();
});
});
});
117 changes: 117 additions & 0 deletions src/containers/CourseCard/components/CourseCardMeta/hooks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { useMemo } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { utilHooks, useCourseData } from 'hooks';
import {
useInitializeLearnerHome, useCourseCompletion, useCourseAssignmentsCardInfo,
} from 'data/hooks';
import { getTransformedCourseDataList } from 'utils/dataTransformers';
import { parseDateOnly } from 'utils/dateFormatter';

import messages from './messages';

/**
* NOTE: duration, rating, per-course progress %, and due date are not yet exposed by the
* learner-home API. Until that data exists, this hook fills them in with fixed placeholders
* (and, for due date, a deterministic-but-varied offset per card) so the card layout can be
* built and demoed now. Swap these for real `course.*` fields as the API grows to support them.
*/
const PLACEHOLDER_DURATION_HOURS = 4;
const PLACEHOLDER_RATING = 4.5;
const PLACEHOLDER_PROGRESS_PERCENT = 35;
const DUE_SOON_WINDOW_DAYS = 7;
const MS_PER_DAY = 1000 * 60 * 60 * 24;

// Deterministic per-card day offset (relative to today) used only when no real due date
// exists, so a list of cards shows a mix of overdue / due-soon / on-track states.
const placeholderDueDateOffset = (cardId) => {
const index = Number(String(cardId).replace(/\D/g, '')) || 0;
const cycleDays = [-10, 3, 21, -2, 9];
return cycleDays[index % cycleDays.length];
};

const formatDurationHours = (estimatedDurationHours) => {
if (estimatedDurationHours === null || estimatedDurationHours === undefined) {
return null;
}
const hours = Number(estimatedDurationHours);
if (Number.isNaN(hours)) {
return null;
}
const rounded = Math.round(hours * 10) / 10;
return rounded % 1 === 0 ? rounded.toFixed(0) : `${rounded}`;
};

const useAllDashboardCourseIds = () => {
const { data } = useInitializeLearnerHome();
return useMemo(() => {
const courseList = getTransformedCourseDataList(data?.courses || []);
return courseList
.map((courseData) => courseData?.courseRun?.courseId)
.filter(Boolean);
}, [data?.courses]);
};

export const useCourseCardMeta = (cardId) => {
const { formatMessage } = useIntl();
const formatDate = utilHooks.useFormatDate();
const courseData = useCourseData(cardId);
const { course, enrollment, courseRun } = courseData || {};
const courseId = courseRun?.courseId;

const { data: completionData } = useCourseCompletion();
const realProgressPercent = useMemo(() => {
if (!courseId || !completionData) {
return null;
}
const entry = completionData.find((item) => item.courseId === courseId);
return entry?.completion ?? null;
}, [completionData, courseId]);

const allCourseIds = useAllDashboardCourseIds();
const { data: cardInfoResponse } = useCourseAssignmentsCardInfo(allCourseIds);
const cardInfo = useMemo(() => {
if (!courseId || !cardInfoResponse?.results) {
return null;
}
return cardInfoResponse.results.find((result) => result.course_id === courseId) || null;
}, [cardInfoResponse, courseId]);

const providerDisplayName = cardInfo?.provider_display_name || null;
const contentTypeDisplay = cardInfo?.content_type_display || null;
const realDurationHours = formatDurationHours(cardInfo?.estimated_duration_hours);

const dueDate = useMemo(() => {
if (cardInfo?.due_date) {
return parseDateOnly(cardInfo.due_date);
}
if (course?.dueDate) {
return parseDateOnly(course.dueDate);
}
const placeholder = new Date();
placeholder.setDate(placeholder.getDate() + placeholderDueDateOffset(cardId));
return placeholder;
}, [cardInfo?.due_date, course?.dueDate, cardId]);

const daysUntilDue = Math.ceil((dueDate.getTime() - Date.now()) / MS_PER_DAY);
const isOverdue = cardInfo?.due_date ? Boolean(cardInfo?.is_overdue) : daysUntilDue < 0;
const isDueSoon = !isOverdue && daysUntilDue <= DUE_SOON_WINDOW_DAYS;

const hasStarted = enrollment?.hasStarted || false;
const progressPercent = course?.progressPercent ?? realProgressPercent ?? PLACEHOLDER_PROGRESS_PERCENT;
const rating = course?.rating ?? PLACEHOLDER_RATING;
const durationHours = course?.durationHours ?? realDurationHours ?? PLACEHOLDER_DURATION_HOURS;

return {
providerDisplayName,
contentType: contentTypeDisplay || formatMessage(messages.contentTypeVideo),
durationLabel: formatMessage(messages.durationHours, { hours: durationHours }),
dueDateLabel: formatMessage(messages.dueDate, { dueDate: formatDate(dueDate) }),
rating,
isOverdue,
isDueSoon,
hasStarted,
progressPercent,
};
};

export default useCourseCardMeta;
Loading