Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: display search result cards in catalog tab #1059

Merged
merged 18 commits into from
Oct 27, 2023
Merged
Show file tree
Hide file tree
Changes from 5 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
165 changes: 107 additions & 58 deletions src/components/learner-credit-management/cards/CourseCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,95 +3,144 @@
import React from 'react';
import PropTypes from 'prop-types';

import { camelCaseObject } from '@edx/frontend-platform';
import cardFallbackImg from '@edx/brand/paragon/images/card-imagecap-fallback.png';
import {
Badge, Button, Card, Hyperlink,
Badge,
Button,
Card,
Stack,
Hyperlink,
useMediaQuery,
breakpoints,
} from '@edx/paragon';
import { EXEC_COURSE_TYPE } from '../data/constants';
import { formatDate } from '../data/utils';
import { injectIntl } from '@edx/frontend-platform/i18n';

import { CONTENT_TYPE_COURSE, EXEC_COURSE_TYPE } from '../data';
import { formatCurrency, formatDate, getEnrollmentDeadline } from '../data/utils';
import CARD_TEXT from '../constants';
import defaultLogoImg from '../../../static/default-card-header-dark.png';
import defaultCardImg from '../../../static/default-card-header-light.png';
Copy link
Member

Choose a reason for hiding this comment

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

Both of these images are intended to be the default card image, not a fallback logo image. Also, the fallback card image should not be hosted within the the MFE itself as it should be pulled from @edx/brand instead (docs):

import cardFallbackImg from '@edx/brand/paragon/images/card-imagecap-fallback.png';

Importing from @edx/brand ensures the fallback image for cards are consistent for the @edx/brand-edx.org theme across applications.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done! We don't need a default logo?

Copy link
Member

Choose a reason for hiding this comment

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

I don't feel we do, but could convinced otherwise. The fallbackSrc is important because without it, it messes up the card grid with one or more cards with differing heights without the image. Having a missing logo image doesn't affect the card heights in the same way, so having a fallback isn't as important.


const CourseCard = ({
onClick, original,
original, learningType,
katrinan029 marked this conversation as resolved.
Show resolved Hide resolved
}) => {
const {
title,
cardImageUrl,
courseType,
normalizedMetadata,
availability,
card_image_url,
course_type,
entitlements,
first_enrollable_paid_seat_price,
normalized_metadata,
partners,
} = camelCaseObject(original);
title,
} = original;
katrinan029 marked this conversation as resolved.
Show resolved Hide resolved

const isSmall = useMediaQuery({ maxWidth: breakpoints.small.maxWidth });
const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.extraSmall.maxWidth });

const {
BADGE,
BUTTON_ACTION,
PRICE,
ENROLLMENT,
} = CARD_TEXT;

let priceText;

if (learningType === CONTENT_TYPE_COURSE) {
priceText = first_enrollable_paid_seat_price != null ? `${formatCurrency(first_enrollable_paid_seat_price)}` : 'N/A';
} else {
const [firstEntitlement] = entitlements || [null];
priceText = firstEntitlement != null ? `${formatCurrency(firstEntitlement?.price)}` : 'N/A';
}
katrinan029 marked this conversation as resolved.
Show resolved Hide resolved

const imageSrc = card_image_url || defaultCardImg;
const logoSrc = partners[0]?.logo_image_url || defaultLogoImg;
katrinan029 marked this conversation as resolved.
Show resolved Hide resolved

const altText = `${title} course image`;

const formatAvailability = availability?.length ? availability.join(', ') : null;
katrinan029 marked this conversation as resolved.
Show resolved Hide resolved

const enrollmentDeadline = getEnrollmentDeadline(normalized_metadata?.enroll_by_date);

let courseEnrollmentInfo;
let execEdEnrollmentInfo;
if (normalized_metadata?.enroll_by_date) {
courseEnrollmentInfo = `${formatAvailability} • ${ENROLLMENT.text} ${enrollmentDeadline}`;
execEdEnrollmentInfo = `Starts ${formatDate(normalized_metadata.start_date)} •
${ENROLLMENT.text} ${enrollmentDeadline}`;
} else {
courseEnrollmentInfo = formatAvailability;
execEdEnrollmentInfo = formatAvailability;
}

const isExecEd = course_type === EXEC_COURSE_TYPE;

return (
<Card
className="course-card"
onClick={() => onClick(original)}
orientation="horizontal"
tabIndex="0"
isClickable
katrinan029 marked this conversation as resolved.
Show resolved Hide resolved
orientation={isSmall ? 'vertical' : 'horizontal'}
>
<Card.ImageCap
src={cardImageUrl || cardFallbackImg}
fallbackSrc={cardFallbackImg}
logoSrc={partners[0]?.logo_image_url}
src={imageSrc}
logoSrc={logoSrc}
srcAlt={altText}
logoAlt={partners[0]?.name}
/>
<div className="card-container">
<div className="section-1">
<p className="mb-1 lead font-weight-bold">{title}</p>
<p>{partners[0]?.name}</p>
{courseType === EXEC_COURSE_TYPE && (
<Badge variant="light" className="mb-4">
Executive Education
</Badge>
<Card.Body>
<Card.Header
title={title}
className="mb-0 mt-0"
subtitle={partners[0]?.name}
actions={(
<Stack gap={1} className="text-right">
<p className="h4 mt-2.5 mb-0">{priceText}</p>
<span className="micro">{PRICE.subText}</span>
</Stack>
)}
{courseType !== EXEC_COURSE_TYPE && (
<p className="spacer" />
)}
<p className={`small ${courseType !== EXEC_COURSE_TYPE ? 'mt-5 mb-0' : ''}`}>
Starts {formatDate(normalizedMetadata?.start_date)} •
Learner must register by {formatDate(normalizedMetadata?.enroll_by_date)}
</p>
</div>
<Card.Section className="section-2">
<p className="lead font-weight-bold mb-0">{priceText}</p>
<p className="micro mb-5.5">Per learner price</p>
<Card.Footer orientation="horizontal" className="footer">
<Button as={Hyperlink} destination="https://edx.org" target="_blank">View course</Button>

<Button>Assign</Button>
</Card.Footer>
/>
<Card.Section>
<Badge variant="light" className="ml-0">
katrinan029 marked this conversation as resolved.
Show resolved Hide resolved
{isExecEd ? BADGE.execEd : BADGE.course}
</Badge>
</Card.Section>
</div>
<Card.Footer
orientation={isExtraSmall ? 'horizontal' : 'vertical'}
katrinan029 marked this conversation as resolved.
Show resolved Hide resolved
textElement={isExecEd ? execEdEnrollmentInfo : courseEnrollmentInfo}
>
<Button
// TODO: Implementation to follow in ENT-7594
as={Hyperlink}
destination="https://enterprise.stage.edx.org"
target="_blank"
variant="outline-primary"
>
{BUTTON_ACTION.viewCourse}
</Button>
<Button>{BUTTON_ACTION.assign}</Button>
</Card.Footer>
</Card.Body>
</Card>
);
};

CourseCard.defaultProps = {
onClick: () => {},
};

CourseCard.propTypes = {
onClick: PropTypes.func,
learningType: PropTypes.string.isRequired,
original: PropTypes.shape({
title: PropTypes.string,
cardImageUrl: PropTypes.string,
availability: PropTypes.string,
katrinan029 marked this conversation as resolved.
Show resolved Hide resolved
card_image_url: PropTypes.string,
course_type: PropTypes.string,
entitlements: PropTypes.arrayOf(PropTypes.shape()),
first_enrollable_paid_seat_price: PropTypes.number,
normalized_metadata: PropTypes.shape(),
original_image_url: PropTypes.string,
partners: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string,
logo_image_url: PropTypes.string,
name: PropTypes.string,
}),
),
normalizedMetadata: PropTypes.shape({
startDate: PropTypes.string,
endDate: PropTypes.string,
enrollByDate: PropTypes.string,
}),
courseType: PropTypes.string,
title: PropTypes.string,
}).isRequired,
};

export default CourseCard;
export default injectIntl(CourseCard);
68 changes: 37 additions & 31 deletions src/components/learner-credit-management/cards/CourseCard.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,20 @@ import '@testing-library/jest-dom/extend-expect';

import { IntlProvider } from '@edx/frontend-platform/i18n';
import CourseCard from './CourseCard';
import { CONTENT_TYPE_COURSE, EXEC_ED_TITLE } from '../data/constants';

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

const TEST_CATALOG = ['ayylmao'];
import { CONTENT_TYPE_COURSE, EXEC_COURSE_TYPE } from '../data';

const originalData = {
title: 'Course Title',
availability: ['Upcoming'],
card_image_url: undefined,
partners: [{ logo_image_url: '', name: 'Course Provider' }],
course_type: 'course',
first_enrollable_paid_seat_price: 100,
normalized_metadata: {
enroll_by_date: '2016-02-18T04:00:00Z',
start_date: '2016-04-18T04:00:00Z',
},
original_image_url: '',
enterprise_catalog_query_titles: TEST_CATALOG,
advertised_course_run: { pacing_type: 'self_paced' },
partners: [{ logo_image_url: '', name: 'Course Provider' }],
title: 'Course Title',
};

const defaultProps = {
Expand All @@ -28,23 +26,27 @@ const defaultProps = {
};

const execEdData = {
title: 'Exec Ed Course Title',
availability: ['Upcoming'],
card_image_url: undefined,
partners: [{ logo_image_url: '', name: 'Course Provider' }],
course_type: 'executive-education-2u',
entitlements: [{ price: '999.00' }],
first_enrollable_paid_seat_price: 100,
normalized_metadata: {
enroll_by_date: '2016-02-18T04:00:00Z',
start_date: '2016-04-18T04:00:00Z',
},
original_image_url: '',
enterprise_catalog_query_titles: TEST_CATALOG,
advertised_course_run: { pacing_type: 'instructor_paced' },
entitlements: [{ price: '999.00' }],
partners: [{ logo_image_url: '', name: 'Course Provider' }],
title: 'Exec Ed Title',
};

const execEdProps = {
learningType: EXEC_COURSE_TYPE,
original: execEdData,
learningType: EXEC_ED_TITLE,
};

describe('Course card works as expected', () => {
test('card renders as expected', () => {
test('course card renders', () => {
render(
<IntlProvider locale="en">
<CourseCard {...defaultProps} />
Expand All @@ -54,21 +56,14 @@ describe('Course card works as expected', () => {
expect(
screen.queryByText(defaultProps.original.partners[0].name),
).toBeInTheDocument();
expect(screen.queryByText('Course Title')).toBeInTheDocument();
expect(screen.queryByText('$100')).toBeInTheDocument();
expect(screen.queryByText('Per learner price')).toBeInTheDocument();
expect(screen.queryByText('Upcoming • Learner must enroll by Feb 18, 2016')).toBeInTheDocument();
expect(screen.queryByText('Course')).toBeInTheDocument();
expect(screen.queryByText('View Course')).toBeInTheDocument();
expect(screen.queryByText('Assign')).toBeInTheDocument();
});
test('exec ed card renders as expected', () => {
render(
<IntlProvider locale="en">
<CourseCard {...execEdProps} />
</IntlProvider>,
);
expect(screen.queryByText(execEdProps.original.title)).toBeInTheDocument();
expect(
screen.queryByText(execEdProps.original.partners[0].name),
).toBeInTheDocument();
expect(screen.queryByText('Exec Ed Course Title')).toBeInTheDocument();
});

test('test card renders default image', async () => {
render(
<IntlProvider locale="en">
Expand All @@ -79,4 +74,15 @@ describe('Course card works as expected', () => {
fireEvent.error(screen.getByAltText(imageAltText));
await expect(screen.getByAltText(imageAltText).src).not.toBeUndefined;
});

test('exec ed card renders', async () => {
render(
<IntlProvider locale="en">
<CourseCard {...execEdProps} />
</IntlProvider>,
);
expect(screen.queryByText('$999')).toBeInTheDocument();
expect(screen.queryByText('Starts Apr 18, 2016 • Learner must enroll by Feb 18, 2016')).toBeInTheDocument();
expect(screen.queryByText('Executive Education')).toBeInTheDocument();
});
});
18 changes: 18 additions & 0 deletions src/components/learner-credit-management/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const CARD_TEXT = {
BADGE: {
course: 'Course',
execEd: 'Executive Education',
},
BUTTON_ACTION: {
viewCourse: 'View Course',
katrinan029 marked this conversation as resolved.
Show resolved Hide resolved
assign: 'Assign',
},
ENROLLMENT: {
text: 'Learner must enroll by',
},
PRICE: {
subText: 'Per learner price',
},
};

export default CARD_TEXT;
3 changes: 3 additions & 0 deletions src/components/learner-credit-management/data/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,6 @@ export const CONTENT_TYPE_COURSE = 'course';
export const EXEC_ED_TITLE = 'Executive Education';

export const EXEC_COURSE_TYPE = 'executive-education-2u';
katrinan029 marked this conversation as resolved.
Show resolved Hide resolved

// Learner must enroll within 90 days of assignment
export const ASSIGNMENT_ENROLLMENT_DEADLINE = 90;
22 changes: 22 additions & 0 deletions src/components/learner-credit-management/data/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import dayjs from 'dayjs';
import {
LOW_REMAINING_BALANCE_PERCENT_THRESHOLD,
NO_BALANCE_REMAINING_DOLLAR_THRESHOLD,
ASSIGNMENT_ENROLLMENT_DEADLINE,
} from './constants';
import { BUDGET_STATUSES } from '../../EnterpriseApp/data/constants';
/**
Expand Down Expand Up @@ -187,3 +188,24 @@ export const orderOffers = (offers) => {
export function formatDate(date) {
return dayjs(date).format('MMM D, YYYY');
}

export function formatCurrency(currency) {
const currencyUS = Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
});

return currencyUS.format(currency);
}
katrinan029 marked this conversation as resolved.
Show resolved Hide resolved

// Exec ed and open courses cards should display either the enrollment deadline
// or 90 days from the present date on user pageload, whichever is sooner.
export function getEnrollmentDeadline(enrollByDate) {
katrinan029 marked this conversation as resolved.
Show resolved Hide resolved
katrinan029 marked this conversation as resolved.
Show resolved Hide resolved
const currentDate = new Date();
const enrollmentDeadline = new Date(currentDate.setDate(currentDate.getDate() + ASSIGNMENT_ENROLLMENT_DEADLINE));
katrinan029 marked this conversation as resolved.
Show resolved Hide resolved
const courseEnrollByDate = new Date(enrollByDate);
return enrollmentDeadline > courseEnrollByDate
? formatDate(courseEnrollByDate)
: formatDate(enrollmentDeadline);
}
1 change: 0 additions & 1 deletion src/components/learner-credit-management/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import React from 'react';
import { Route } from 'react-router-dom';
import PropTypes from 'prop-types';
import MultipleBudgetsPage from './MultipleBudgetsPage';
import './learner-credit.scss';
import BudgetDetailPage from './BudgetDetailPage';

const LearnerCreditManagementRoutes = ({ match }) => (
Expand Down
Loading