Skip to content

Commit b55bb1d

Browse files
authored
Merge pull request #6 from open-craft/agrendalath/bb-9669-enrollments
feat: implement enrollments
2 parents 45ea53c + fba71b7 commit b55bb1d

13 files changed

+528
-220
lines changed

package-lock.json

+2-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@open-craft/frontend-app-learning-paths",
3-
"version": "0.1.0",
3+
"version": "0.1.1",
44
"description": "Frontend application template",
55
"repository": {
66
"type": "git",

src/index.scss

-19
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,3 @@
77

88
@import "~@edx/frontend-component-header/dist/index";
99
@import "~@edx/frontend-component-footer/dist/footer";
10-
11-
// Make the footer stick to the bottom when there is not enough content.
12-
html, body, #root {
13-
height: 100%;
14-
margin: 0;
15-
}
16-
17-
#root {
18-
display: flex;
19-
flex-direction: column;
20-
}
21-
22-
#main-content {
23-
flex: 1 0 auto;
24-
}
25-
26-
.footer {
27-
flex-shrink: 0;
28-
}

src/learningpath/CourseCard.jsx

+81-13
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useMemo } from 'react';
1+
import React, { useMemo, useState } from 'react';
22
import PropTypes from 'prop-types';
33
import { Link } from 'react-router-dom';
44
import {
@@ -12,17 +12,20 @@ import {
1212
Timelapse,
1313
} from '@openedx/paragon/icons';
1414
import { buildAssetUrl } from '../util/assetUrl';
15-
import { usePrefetchCourseDetail } from './data/queries';
15+
import { usePrefetchCourseDetail, useCourseEnrollmentStatus, useEnrollCourse } from './data/queries';
16+
import { buildCourseHomeUrl } from './utils';
1617

17-
const CourseCard = ({ course, parentPath, onClick }) => {
18-
const courseKey = `course-v1:${course.org}+${course.courseId}+${course.run}`;
18+
export const CourseCard = ({ course, parentPath, onClick }) => {
19+
const courseKey = course.id;
1920
const {
2021
name,
2122
org,
2223
courseImageAssetPath,
2324
endDate,
2425
status,
2526
percent,
27+
isEnrolledInCourse,
28+
checkingEnrollment,
2629
} = course;
2730

2831
// Prefetch the course detail when the user hovers over the card.
@@ -34,8 +37,8 @@ const CourseCard = ({ course, parentPath, onClick }) => {
3437
const progressBarPercent = useMemo(() => Math.round(percent * 100), [percent]);
3538

3639
const linkTo = parentPath
37-
? `${parentPath}/course/${encodeURIComponent(courseKey)}`
38-
: `/course/${encodeURIComponent(courseKey)}`;
40+
? `${parentPath}/course/${courseKey}`
41+
: `/course/${courseKey}`;
3942

4043
const handleViewClick = (e) => {
4144
if (onClick) {
@@ -71,6 +74,23 @@ const CourseCard = ({ course, parentPath, onClick }) => {
7174
year: 'numeric',
7275
})
7376
: null;
77+
78+
let buttonText = 'View';
79+
let buttonVariant = 'outline-primary';
80+
81+
// Update the button based on enrollment status (if available).
82+
if (isEnrolledInCourse === false) {
83+
buttonText = 'Start';
84+
buttonVariant = 'primary';
85+
} else if (isEnrolledInCourse === true) {
86+
buttonText = 'Resume';
87+
buttonVariant = 'outline-success';
88+
}
89+
90+
if (checkingEnrollment) {
91+
buttonText = 'Loading...';
92+
}
93+
7494
return (
7595
<Card className="course-card p-3" onMouseEnter={handleMouseEnter}>
7696
<div className="lp-status-badge">
@@ -114,7 +134,9 @@ const CourseCard = ({ course, parentPath, onClick }) => {
114134
)}
115135
</div>
116136
{onClick ? (
117-
<Button variant="outline-primary" onClick={handleViewClick}>View</Button>
137+
<Button variant={buttonVariant} onClick={handleViewClick} disabled={checkingEnrollment}>
138+
{buttonText}
139+
</Button>
118140
) : (
119141
<Link to={linkTo}>
120142
<Button variant="outline-primary">View</Button>
@@ -129,22 +151,68 @@ const CourseCard = ({ course, parentPath, onClick }) => {
129151

130152
CourseCard.propTypes = {
131153
course: PropTypes.shape({
154+
id: PropTypes.string.isRequired,
132155
name: PropTypes.string.isRequired,
133156
org: PropTypes.string.isRequired,
134-
courseId: PropTypes.string.isRequired,
135-
run: PropTypes.string.isRequired,
136157
courseImageAssetPath: PropTypes.string,
137158
endDate: PropTypes.string,
138159
status: PropTypes.string.isRequired,
139160
percent: PropTypes.number.isRequired,
161+
isEnrolledInCourse: PropTypes.bool,
162+
checkingEnrollment: PropTypes.bool,
140163
}).isRequired,
141164
parentPath: PropTypes.string,
142165
onClick: PropTypes.func,
143166
};
144167

145-
CourseCard.defaultProps = {
146-
parentPath: undefined,
147-
onClick: undefined,
168+
export const CourseCardWithEnrollment = ({ course, learningPathId }) => {
169+
const { data: enrollmentStatus, isLoading: checkingEnrollment } = useCourseEnrollmentStatus(course.id);
170+
const [enrolling, setEnrolling] = useState(false);
171+
const enrollCourseMutation = useEnrollCourse(learningPathId);
172+
173+
const courseWithEnrollment = {
174+
...course,
175+
isEnrolledInCourse: enrollmentStatus?.isEnrolled || false,
176+
checkingEnrollment: checkingEnrollment || enrolling,
177+
};
178+
179+
// Defined here because calling the MFE config API from an async function can randomly fail.
180+
const courseHomeUrl = buildCourseHomeUrl(course.id);
181+
182+
const handleCourseAction = async () => {
183+
if (courseWithEnrollment.isEnrolledInCourse) {
184+
window.location.href = courseHomeUrl;
185+
return;
186+
}
187+
188+
setEnrolling(true);
189+
try {
190+
const result = await enrollCourseMutation.mutateAsync(course.id);
191+
if (result.success) {
192+
window.location.href = courseHomeUrl;
193+
} else {
194+
// eslint-disable-next-line no-console
195+
console.error('Failed to enroll in the course:', result.data?.error || 'Unknown error');
196+
}
197+
} catch (error) {
198+
// eslint-disable-next-line no-console
199+
console.error('Failed to enroll in the course:', error);
200+
} finally {
201+
setEnrolling(false);
202+
}
203+
};
204+
205+
return (
206+
<CourseCard
207+
course={courseWithEnrollment}
208+
onClick={handleCourseAction}
209+
/>
210+
);
148211
};
149212

150-
export default CourseCard;
213+
CourseCardWithEnrollment.propTypes = {
214+
course: PropTypes.shape({
215+
id: PropTypes.string.isRequired,
216+
}).isRequired,
217+
learningPathId: PropTypes.string.isRequired,
218+
};

src/learningpath/CourseDetails.jsx

+26-41
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import React from 'react';
22
import PropTypes from 'prop-types';
33
import { useParams, Link, useNavigate } from 'react-router-dom';
4-
import { getConfig } from '@edx/frontend-platform';
54
import {
65
Spinner,
76
Card,
@@ -22,9 +21,10 @@ import {
2221
Close,
2322
} from '@openedx/paragon/icons';
2423
import { useCourseDetail } from './data/queries';
25-
import { buildAssetUrl } from '../util/assetUrl';
24+
import { buildAssetUrl, replaceStaticAssetReferences } from '../util/assetUrl';
25+
import { buildCourseHomeUrl } from './utils';
2626

27-
const CourseDetailContent = ({ course, isModalView, onClose }) => {
27+
const CourseDetailContent = ({ course, isModalView = false, onClose }) => {
2828
const {
2929
name,
3030
shortDescription,
@@ -53,14 +53,6 @@ const CourseDetailContent = ({ course, isModalView, onClose }) => {
5353
const handleClose = onClose || (() => navigate(-1));
5454
const { courseKey: urlCourseKey } = useParams();
5555
const activeCourseKey = course.id || urlCourseKey;
56-
const learningMfeBase = getConfig().LEARNING_BASE_URL;
57-
const buildCourseHomeUrl = (key) => {
58-
const trimmedBase = learningMfeBase.replace(/\/$/, '');
59-
const sanitizedBase = trimmedBase.endsWith('/learning')
60-
? trimmedBase
61-
: `${trimmedBase}/learning`;
62-
return `${sanitizedBase}/course/${key}/home`;
63-
};
6456
const handleViewClick = () => {
6557
window.location.href = buildCourseHomeUrl(activeCourseKey);
6658
};
@@ -133,17 +125,17 @@ const CourseDetailContent = ({ course, isModalView, onClose }) => {
133125
</div>
134126
</Col>
135127
)}
136-
{selfPaced === true && (
137-
<Col xs={6} md={3} className="mb-3">
138-
<div className="d-flex align-items-center">
139-
<Icon src={Person} className="mr-4 mb-4" />
140-
<div>
141-
<p className="mb-1 font-weight-bold">Self-paced</p>
142-
<p className="text-muted">Learn at your own speed</p>
143-
</div>
128+
<Col xs={6} md={3} className="mb-3">
129+
<div className="d-flex align-items-center">
130+
<Icon src={Person} className="mr-4 mb-4" />
131+
<div>
132+
<p className="mb-1 font-weight-bold">{selfPaced ? 'Self-paced' : 'Instructor-paced'}</p>
133+
<p className="text-muted">
134+
{selfPaced ? 'Learn at your own speed' : 'Follow the course schedule'}
135+
</p>
144136
</div>
145-
</Col>
146-
)}
137+
</div>
138+
</Col>
147139
</Row>
148140
</div>
149141

@@ -153,19 +145,24 @@ const CourseDetailContent = ({ course, isModalView, onClose }) => {
153145
<Nav.Link eventKey="about">About</Nav.Link>
154146
</Nav.Item>
155147
</Nav>
156-
<Button
157-
variant="primary"
158-
className="ml-auto"
159-
onClick={handleViewClick}
160-
>
161-
View
162-
</Button>
148+
{!isModalView && (
149+
<Button
150+
variant="primary"
151+
className="ml-auto"
152+
onClick={handleViewClick}
153+
>
154+
View
155+
</Button>
156+
)}
163157
</div>
164158

165159
<div className="p-4">
166160
<section id="about" className="mb-6">
167161
{/* eslint-disable-next-line react/no-danger */}
168-
<div dangerouslySetInnerHTML={{ __html: description || shortDescription || 'No description available.' }} />
162+
<div dangerouslySetInnerHTML={{
163+
__html: replaceStaticAssetReferences(description || shortDescription || 'No description available.', course.id),
164+
}}
165+
/>
169166
</section>
170167
</div>
171168
</>
@@ -187,11 +184,6 @@ CourseDetailContent.propTypes = {
187184
onClose: PropTypes.func,
188185
};
189186

190-
CourseDetailContent.defaultProps = {
191-
isModalView: false,
192-
onClose: undefined,
193-
};
194-
195187
const CourseDetailPage = ({ isModalView = false, onClose, courseKey: propCourseKey }) => {
196188
const { courseKey: urlCourseKey } = useParams();
197189
const courseKey = propCourseKey || urlCourseKey;
@@ -231,7 +223,6 @@ const CourseDetailPage = ({ isModalView = false, onClose, courseKey: propCourseK
231223
shortDescription: course.shortDescription || '',
232224
description: course.description || course.shortDescription || '',
233225
duration: course.duration || '',
234-
selfPaced: course.selfPaced !== undefined ? course.selfPaced : true,
235226
};
236227

237228
return (
@@ -247,10 +238,4 @@ CourseDetailPage.propTypes = {
247238
courseKey: PropTypes.string,
248239
};
249240

250-
CourseDetailPage.defaultProps = {
251-
isModalView: false,
252-
onClose: undefined,
253-
courseKey: undefined,
254-
};
255-
256241
export default CourseDetailPage;

src/learningpath/Dashboard.jsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
} from '@openedx/paragon';
55
import { useLearningPaths, useCourses } from './data/queries';
66
import LearningPathCard from './LearningPathCard';
7-
import CourseCard from './CourseCard';
7+
import { CourseCard } from './CourseCard';
88
import FilterPanel from './FilterPanel';
99

1010
const Dashboard = () => {
@@ -77,7 +77,7 @@ const Dashboard = () => {
7777
)}
7878
<Row>
7979
{filteredItems.map(item => (item.type === 'course' ? (
80-
<Col key={`course-v1:${item.org}+${item.courseId}+${item.run}`} xs={12} lg={8} className="mb-4 ml-6">
80+
<Col key={item.id} xs={12} lg={8} className="mb-4 ml-6">
8181
<CourseCard course={item} />
8282
</Col>
8383
) : (

src/learningpath/LearningPathCard.jsx

+4-5
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,12 @@ import {
1717
FormatListBulleted,
1818
AccessTime,
1919
} from '@openedx/paragon/icons';
20-
import { buildAssetUrl } from '../util/assetUrl';
2120
import { usePrefetchLearningPathDetail } from './data/queries';
2221

2322
const LearningPathCard = ({ learningPath }) => {
2423
const {
2524
key,
26-
imageUrl,
25+
image,
2726
displayName,
2827
subtitle,
2928
duration,
@@ -80,9 +79,9 @@ const LearningPathCard = ({ learningPath }) => {
8079
</div>
8180
<Row>
8281
<Col xs={12} md={4} className="lp-card-image-col">
83-
{imageUrl && (
82+
{image && (
8483
<Card.ImageCap
85-
src={buildAssetUrl(imageUrl)}
84+
src={image}
8685
alt={displayName}
8786
className="lp-card-image"
8887
/>
@@ -135,7 +134,7 @@ const LearningPathCard = ({ learningPath }) => {
135134
LearningPathCard.propTypes = {
136135
learningPath: PropTypes.shape({
137136
key: PropTypes.string.isRequired,
138-
imageUrl: PropTypes.string,
137+
image: PropTypes.string,
139138
displayName: PropTypes.string.isRequired,
140139
subtitle: PropTypes.string,
141140
duration: PropTypes.string,

0 commit comments

Comments
 (0)