-
-
-
Related Learning Path{relatedLearningPaths.length > 1 ? 's' : ''}:
+
+ {/* Duration */}
+
+
+ 20 Hours
+
+
+ {/* Start Date */}
+
+
+ Starts {dateDisplay}
-
- {relatedLearningPaths.map((learningPath) => (
-
- {learningPath.name}
-
- ))}
-
-
- )}
+
+
+
+
+
+ More Details
+
+
+ {!disableStartButton && (
+
+ { buttonText }
+
+ )}
+
+
+
>
);
};
@@ -202,10 +191,11 @@ CourseCard.propTypes = {
onClickViewButton: PropTypes.func,
isEnrolledInLearningPath: PropTypes.bool,
showFilters: PropTypes.bool,
+ orientationOverride: PropTypes.oneOf(['vertical', 'horizontal']),
};
export const CourseCardWithEnrollment = ({
- course, learningPathId, isEnrolledInLearningPath, onClick,
+ course, learningPathId, isEnrolledInLearningPath, onClick, orientationOverride,
}) => {
const { data: enrollmentStatus, isLoading: checkingEnrollment } = useCourseEnrollmentStatus(course.id);
const [enrolling, setEnrolling] = useState(false);
@@ -217,7 +207,6 @@ export const CourseCardWithEnrollment = ({
checkingEnrollment: checkingEnrollment || enrolling,
};
- // Defined here because calling the MFE config API from an async function can randomly fail.
const courseHomeUrl = buildCourseHomeUrl(course.id);
const handleCourseAction = async () => {
@@ -251,6 +240,8 @@ export const CourseCardWithEnrollment = ({
onClick={handleCourseAction}
onClickViewButton={onClick}
isEnrolledInLearningPath={isEnrolledInLearningPath}
+ orientationOverride={orientationOverride}
+ isEnrolledInCourse={courseWithEnrollment.isEnrolledInCourse}
/>
);
};
@@ -262,4 +253,5 @@ CourseCardWithEnrollment.propTypes = {
learningPathId: PropTypes.string.isRequired,
isEnrolledInLearningPath: PropTypes.bool,
onClick: PropTypes.func.isRequired,
+ orientationOverride: PropTypes.oneOf(['vertical', 'horizontal']),
};
diff --git a/src/learningpath/Dashboard.jsx b/src/learningpath/Dashboard.jsx
index 118acf3f..d9b8255f 100644
--- a/src/learningpath/Dashboard.jsx
+++ b/src/learningpath/Dashboard.jsx
@@ -9,7 +9,6 @@ import { getConfig } from '@edx/frontend-platform';
import { FilterAlt, FilterList, Search } from '@openedx/paragon/icons';
import { useLearningPaths, useLearnerDashboard, useOrganizations } from './data/queries';
import LearningPathCard from './LearningPathCard';
-import { CourseCard } from './CourseCard';
import FilterPanel from './FilterPanel';
import { useScreenSize } from '../hooks/useScreenSize';
import noResultsSVG from '../assets/no_results.svg';
@@ -25,8 +24,8 @@ const Dashboard = () => {
const {
data: learnerDashboardData,
- isLoading: isLoadingCourses,
- error: coursesError,
+ isLoading: isLoadingDashboard,
+ error: dashboardError,
} = useLearnerDashboard();
const {
@@ -34,25 +33,22 @@ const Dashboard = () => {
isLoading: isLoadingOrgs,
} = useOrganizations();
- const courses = learnerDashboardData?.courses;
const emailConfirmation = learnerDashboardData?.emailConfirmation;
const enterpriseDashboard = learnerDashboardData?.enterpriseDashboard;
- const isLoading = isLoadingPaths || isLoadingCourses || isLoadingOrgs;
- const error = pathsError || coursesError;
+ const isLoading = isLoadingPaths || isLoadingDashboard || isLoadingOrgs;
+ const error = pathsError || dashboardError;
if (error) {
- // eslint-disable-next-line no-console
console.error('Error loading data:', error);
}
const items = useMemo(() => {
- // If email confirmation is needed, return empty array to hide all items.
if (emailConfirmation?.isNeeded) {
return [];
}
- return [...(courses || []), ...(learningPaths || [])];
- }, [courses, learningPaths, emailConfirmation]);
+ return learningPaths || [];
+ }, [learningPaths, emailConfirmation]);
const [searchQuery, setSearchQuery] = useState('');
const [showMobileSearch, setShowMobileSearch] = useState(false);
@@ -60,7 +56,6 @@ const Dashboard = () => {
const handleMobileSearchClick = () => {
setShowMobileSearch(true);
- // Focus the search field after it becomes visible.
setTimeout(() => {
if (mobileSearchRef.current) {
const inputElement = mobileSearchRef.current.querySelector('input');
@@ -72,20 +67,19 @@ const Dashboard = () => {
};
const handleMobileSearchBlur = () => {
- // Hide mobile search when user taps outside and the search query is empty.
if (isSmall && !searchQuery) {
setShowMobileSearch(false);
}
};
const showFiltersKey = 'lp_dashboard_showFilters';
- const selectedContentTypeKey = 'lp_dashboard_contentType';
const selectedStatusesKey = 'lp_dashboard_selectedStatuses';
const selectedDateStatusesKey = 'lp_dashboard_selectedDateStatuses';
const selectedOrgsKey = 'lp_dashboard_selectedOrgs';
- const [showFilters, setShowFilters] = useState(() => localStorage.getItem(showFiltersKey) === 'true');
- const [selectedContentType, setSelectedContentType] = useState(() => localStorage.getItem(selectedContentTypeKey) || 'All');
+ const [showFilters, setShowFilters] = useState(
+ () => localStorage.getItem(showFiltersKey) === 'true',
+ );
const [selectedStatuses, setSelectedStatuses] = useState(
() => JSON.parse(localStorage.getItem(selectedStatusesKey)) || [],
);
@@ -96,16 +90,18 @@ const Dashboard = () => {
() => JSON.parse(localStorage.getItem(selectedOrgsKey)) || [],
);
- useEffect(() => { localStorage.setItem(showFiltersKey, showFilters.toString()); }, [showFilters]);
useEffect(() => {
- localStorage.setItem(selectedContentTypeKey, selectedContentType.toString());
- }, [selectedContentType]);
- useEffect(() => { localStorage.setItem(selectedStatusesKey, JSON.stringify(selectedStatuses)); }, [selectedStatuses]);
+ localStorage.setItem(showFiltersKey, showFilters.toString());
+ }, [showFilters]);
+ useEffect(() => {
+ localStorage.setItem(selectedStatusesKey, JSON.stringify(selectedStatuses));
+ }, [selectedStatuses]);
useEffect(() => {
localStorage.setItem(selectedDateStatusesKey, JSON.stringify(selectedDateStatuses));
}, [selectedDateStatuses]);
- useEffect(() => { localStorage.setItem(selectedOrgsKey, JSON.stringify(selectedOrgs)); }, [selectedOrgs]);
- useEffect(() => { localStorage.setItem(selectedOrgsKey, JSON.stringify(selectedOrgs)); }, [selectedOrgs]);
+ useEffect(() => {
+ localStorage.setItem(selectedOrgsKey, JSON.stringify(selectedOrgs));
+ }, [selectedOrgs]);
const handleStatusChange = (status, isChecked) => {
setSelectedStatuses(prev => {
@@ -135,20 +131,18 @@ const Dashboard = () => {
};
const handleClearFilters = () => {
- setSelectedContentType('All');
setSelectedStatuses([]);
setSelectedDateStatuses([]);
setSelectedOrgs([]);
};
- // Get only the organizations that are present in the user's items.
const availableOrganizations = useMemo(() => {
if (!organizations || !items.length) { return {}; }
const availableOrgKeys = new Set();
items.forEach(item => {
- if (item.org) {
- availableOrgKeys.add(item.org);
+ if (item.partner?.slug) {
+ availableOrgKeys.add(item.partner.slug);
}
});
@@ -163,25 +157,14 @@ const Dashboard = () => {
}, [organizations, items]);
const activeFiltersCount = useMemo(
- () => (selectedContentType !== 'All') + selectedStatuses.length + selectedDateStatuses.length + selectedOrgs.length,
- [selectedContentType, selectedStatuses, selectedDateStatuses, selectedOrgs],
+ () => selectedStatuses.length + selectedDateStatuses.length + selectedOrgs.length,
+ [selectedStatuses, selectedDateStatuses, selectedOrgs],
);
- const getItemDates = (item) => {
- if (item.type === 'course') {
- return {
- startDate: item.startDate ? new Date(item.startDate) : null,
- endDate: item.endDate ? new Date(item.endDate) : null,
- };
- }
- if (item.type === 'learning_path') {
- return {
- startDate: item.minDate ? new Date(item.minDate) : null,
- endDate: item.maxDate ? new Date(item.maxDate) : null,
- };
- }
- return { startDate: null, endDate: null };
- };
+ const getItemDates = (item) => ({
+ startDate: item.minDate ? new Date(item.minDate) : null,
+ endDate: item.maxDate ? new Date(item.maxDate) : null,
+ });
const getDateStatus = useCallback((item) => {
const currentDate = new Date();
@@ -196,25 +179,55 @@ const Dashboard = () => {
return 'Open';
}, []);
- const filteredItems = useMemo(() => items.filter(item => {
- const typeMatch = selectedContentType === 'All'
- || (selectedContentType === 'course' && item.type === 'course')
- || (selectedContentType === 'learning_path' && item.type === 'learning_path');
- const statusMatch = selectedStatuses.length === 0 || selectedStatuses.includes(item.status);
- const dateStatusMatch = selectedDateStatuses.length === 0 || selectedDateStatuses.includes(getDateStatus(item));
- const orgMatch = selectedOrgs.length === 0 || selectedOrgs.includes(item.org);
- const searchMatch = searchQuery === ''
- || (item.displayName && item.displayName.toLowerCase().includes(searchQuery.toLowerCase()))
- || (item.name && item.name.toLowerCase().includes(searchQuery.toLowerCase()));
- return typeMatch && statusMatch && dateStatusMatch && orgMatch && searchMatch;
- }), [items, selectedContentType, selectedStatuses, selectedDateStatuses, selectedOrgs, searchQuery, getDateStatus]);
+ const getEffectiveStatus = useCallback((item) => {
+ const rawStatus = item.status;
+ const hasStatus = rawStatus !== null && rawStatus !== undefined && rawStatus !== '';
+ if (hasStatus) {
+ return rawStatus;
+ }
+ if (item.isSelfEnrollment) {
+ return 'Self Enrollment';
+ }
+ return null;
+ }, []);
+
+ const filteredItems = useMemo(
+ () => items.filter(item => {
+ const effectiveStatus = getEffectiveStatus(item);
+
+ const statusMatch =
+ selectedStatuses.length === 0
+ || (effectiveStatus && selectedStatuses.includes(effectiveStatus));
+
+ const dateStatus = getDateStatus(item);
+ const dateStatusMatch =
+ selectedDateStatuses.length === 0
+ || selectedDateStatuses.includes(dateStatus);
+
+ const orgSlug = item.partner?.slug;
+ const orgMatch =
+ selectedOrgs.length === 0
+ || (orgSlug && selectedOrgs.includes(orgSlug));
+
+ const searchMatch = searchQuery === ''
+ || (item.displayName && item.displayName.toLowerCase().includes(searchQuery.toLowerCase()))
+ || (item.name && item.name.toLowerCase().includes(searchQuery.toLowerCase()));
+
+ return statusMatch && dateStatusMatch && orgMatch && searchMatch;
+ }),
+ [items, selectedStatuses, selectedDateStatuses, selectedOrgs, searchQuery, getDateStatus, getEffectiveStatus],
+ );
const sortedItems = useMemo(() => {
- const statusOrder = { 'not started': 1, 'in progress': 2, completed: 3 };
+ const statusOrder = {
+ 'sent': 1,
+ 'accepted': 2,
+ 'completed': 3,
+ 'self enrollment': 4,
+ };
const dateStatusOrder = { Upcoming: 1, Open: 2, Ended: 3 };
return [...filteredItems].sort((a, b) => {
- // 1. Sort by start date category.
const dateStatusA = dateStatusOrder[getDateStatus(a)] || 999;
const dateStatusB = dateStatusOrder[getDateStatus(b)] || 999;
@@ -222,25 +235,27 @@ const Dashboard = () => {
return dateStatusA - dateStatusB;
}
- // 2. Sort by progress status.
- const statusA = statusOrder[a.status?.toLowerCase()] || 999;
- const statusB = statusOrder[b.status?.toLowerCase()] || 999;
+ const effStatusA = getEffectiveStatus(a);
+ const effStatusB = getEffectiveStatus(b);
+
+ const statusA = effStatusA ? (statusOrder[effStatusA.toLowerCase()] || 999) : 999;
+ const statusB = effStatusB ? (statusOrder[effStatusB.toLowerCase()] || 999) : 999;
if (statusA !== statusB) {
return statusA - statusB;
}
- // 3. Sort alphabetically by name.
const nameA = (a.displayName || a.name || '').toLowerCase();
const nameB = (b.displayName || b.name || '').toLowerCase();
return nameA.localeCompare(nameB);
});
- }, [filteredItems, getDateStatus]);
+ }, [filteredItems, getDateStatus, getEffectiveStatus]);
const PAGE_SIZE = getConfig().DASHBOARD_PAGE_SIZE || 10;
const [currentPage, setCurrentPage] = useState(1);
const totalPages = Math.ceil(sortedItems.length / PAGE_SIZE);
+
const paginatedItems = useMemo(() => {
const start = (currentPage - 1) * PAGE_SIZE;
return sortedItems.slice(start, start + PAGE_SIZE);
@@ -250,14 +265,13 @@ const Dashboard = () => {
const totalCount = sortedItems.length;
useEffect(() => {
- // Add a timeout to ensure DOM updates are complete.
const id = setTimeout(() => window.scrollTo({ top: 0, behavior: 'smooth' }), 10);
return () => clearTimeout(id);
}, [currentPage]);
- // Reset pagination when using filters or search.
+
useEffect(() => {
setCurrentPage(1);
- }, [searchQuery, selectedContentType, selectedStatuses, selectedDateStatuses, selectedOrgs]);
+ }, [searchQuery, selectedStatuses, selectedDateStatuses, selectedOrgs]);
return (
<>
@@ -285,8 +299,6 @@ const Dashboard = () => {
{showFilters && (
{
)}
-
My Learning
+
My Catalogs
{!isSmall ? (
setSearchQuery('')}
@@ -313,16 +325,31 @@ const Dashboard = () => {
/>
) : (
-
+
- setShowFilters(true)} />
+ setShowFilters(true)}
+ />
{activeFiltersCount > 0 && (
- {activeFiltersCount}
+
+ {activeFiltersCount}
+
)}
)}
+
{isSmall && showMobileSearch && (
{
/>
)}
+
{!showFilters && !isSmall && (
- setShowFilters(true)} variant="secondary" className="filter-button border-0">
+ setShowFilters(true)}
+ variant="secondary"
+ className="filter-button border-0"
+ >
Filter
)}
@@ -345,7 +377,9 @@ const Dashboard = () => {
Showing {showingCount} of {totalCount}
+
+
{sortedItems.length === 0 ? (
@@ -357,16 +391,17 @@ const Dashboard = () => {
) : (
<>
{paginatedItems.map(item => (
-
- {item.type === 'course'
- ? (
-
- )
- :
}
+
+
))}
+
+ Data Sharing Authorization
+
+
+
+
+
+
+ To enroll in this course through the {partnerName} catalog, we need your permission
+ to share certain information about your activity in this course with {partnerName} .
+ This information will be used solely for tracking and reporting purposes within their training program.
+
+
+ {additionalMessage ? (
+
+ {additionalMessage}
+
+ ) : null}
+
+
+
+
By allowing data sharing, you confirm that:
+
+ You have been invited or your email is eligible for this corporate catalog.
+ You understand that the shared information includes data such as your course progress, grades, and completion status.
+ You consent to {partnerName} receiving this information in accordance with applicable data protection laws (GDPR).
+
+
+
+
+ Do you authorize sharing your data for this course with {partnerName}?
+
+
+
+
+
+
+ Do Not Share
+
+
+ Allow and Continue
+
+
+
+ );
+}
+
+DataSharingAuthorizationModal.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onAllow: PropTypes.func.isRequired,
+ partnerName: PropTypes.string,
+ additionalMessage: PropTypes.string,
+};
diff --git a/src/learningpath/FilterPanel.jsx b/src/learningpath/FilterPanel.jsx
index 1906d5ce..110a5d66 100644
--- a/src/learningpath/FilterPanel.jsx
+++ b/src/learningpath/FilterPanel.jsx
@@ -6,8 +6,6 @@ import {
import { FilterList, Close } from '@openedx/paragon/icons';
const FilterPanel = ({
- selectedContentType,
- onSelectContentType,
selectedStatuses,
onChangeStatus,
selectedDateStatuses,
@@ -23,7 +21,13 @@ const FilterPanel = ({
Filter
{!isSmall && (
- Clear all
+
+ Clear all
+
)}
- {/* Content Type Tabs */}
-
-
- onSelectContentType('All')}
- active={selectedContentType === 'All'}
- >
- All
-
- onSelectContentType('course')}
- active={selectedContentType === 'course'}
- >
- Courses
-
- onSelectContentType('learning_path')}
- active={selectedContentType === 'learning_path'}
- >
- Learning Paths
-
-
-
-
- {/* Status Checkboxes */}
- My Progress
+ My Invitations
onChangeStatus(e.target.value, e.target.checked)}
value={selectedStatuses}
>
- In progress
- Not started
- Completed
+
+ Accepted
+
+
+ Sent
+
+
+ Self Enrollment
+
- {/* Date Status Checkboxes */}
- Course / Learning Path Status
+ Catalog Status
onChangeDateStatus(e.target.value, e.target.checked)}
value={selectedDateStatuses}
>
- Open
- Upcoming
- Ended
+
+ Open
+
+
+ Upcoming
+
+
+ Ended
+
- {/* Organization Checkboxes */}
{organizations && Object.keys(organizations).length > 0 && (
- Program Type
+ Partner
onChangeOrg(e.target.value, e.target.checked)}
value={selectedOrgs}
>
{Object.entries(organizations).map(([shortName, org]) => (
-
+
{org.name || shortName}
))}
@@ -123,19 +113,21 @@ const FilterPanel = ({
)}
- {/* Action Buttons */}
+ {/* Action Buttons (mobile) */}
{isSmall && (
- Clear all
- Apply
+
+ Clear all
+
+
+ Apply
+
)}
);
FilterPanel.propTypes = {
- selectedContentType: PropTypes.oneOf(['All', 'course', 'learning_path']).isRequired,
- onSelectContentType: PropTypes.func.isRequired,
selectedStatuses: PropTypes.arrayOf(PropTypes.string).isRequired,
onChangeStatus: PropTypes.func.isRequired,
selectedDateStatuses: PropTypes.arrayOf(PropTypes.string).isRequired,
@@ -147,10 +139,14 @@ FilterPanel.propTypes = {
name: PropTypes.string,
shortName: PropTypes.string,
}),
- ).isRequired,
+ ),
onClose: PropTypes.func.isRequired,
isSmall: PropTypes.bool.isRequired,
onClearAll: PropTypes.func.isRequired,
};
+FilterPanel.defaultProps = {
+ organizations: {},
+};
+
export default FilterPanel;
diff --git a/src/learningpath/LearningPathCard.jsx b/src/learningpath/LearningPathCard.jsx
index a92c5c5d..80f73959 100644
--- a/src/learningpath/LearningPathCard.jsx
+++ b/src/learningpath/LearningPathCard.jsx
@@ -1,20 +1,15 @@
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
+import { Card, Button, Chip, Icon } from '@openedx/paragon';
import {
- Card,
- Button,
- ProgressBar,
- Chip,
-} from '@openedx/paragon';
-import {
- LmsCompletionSolid,
- CheckCircle,
- Timelapse,
- FormatListBulleted,
+ BookOpen,
AccessTime,
+ Check,
+ ArrowForward,
+ Settings,
} from '@openedx/paragon/icons';
-import { useOrganizations, usePrefetchLearningPathDetail } from './data/queries';
+import { usePrefetchLearningPathDetail } from './data/queries';
import { useScreenSize } from '../hooks/useScreenSize';
const LearningPathCard = ({ learningPath, showFilters = false }) => {
@@ -28,117 +23,185 @@ const LearningPathCard = ({ learningPath, showFilters = false }) => {
status,
minDate,
maxDate,
- percent,
- org,
+ partner,
+ isManager,
} = learningPath;
const { isSmall, isMedium } = useScreenSize();
- const orientation = (showFilters && (isSmall || isMedium)) || (!showFilters && isSmall) ? 'vertical' : 'horizontal';
+ const orientation =
+ (showFilters && (isSmall || isMedium)) || (!showFilters && isSmall)
+ ? 'vertical'
+ : 'horizontal';
- // Prefetch the learning path detail when the user hovers over the card.
const prefetchLearningPathDetail = usePrefetchLearningPathDetail();
- const handleMouseEnter = () => {
- prefetchLearningPathDetail(key);
- };
+ const handleMouseEnter = () => prefetchLearningPathDetail(key);
+
+ let statusVariant = 'pending';
+ let buttonText = 'View Catalog Info';
+ let buttonIcon = Check;
+ let statusAltText = 'Self Enrollment';
- let statusVariant = 'dark';
- let statusIcon = 'fa-circle';
- let buttonText = 'View';
switch (status?.toLowerCase()) {
- case 'completed':
- statusVariant = 'success';
- statusIcon = CheckCircle;
- break;
- case 'not started':
- statusVariant = 'secondary';
- statusIcon = LmsCompletionSolid;
- buttonText = 'Start';
+ case 'sent':
+ statusVariant = 'pending';
+ buttonText = 'View Catalog Info';
+ statusAltText = 'Pending Invitation';
break;
- case 'in progress':
- statusVariant = 'info';
- statusIcon = Timelapse;
- buttonText = 'Resume';
+ case 'accepted':
+ statusVariant = 'accepted';
+ buttonText = 'Go to the catalog';
+ buttonIcon = ArrowForward;
+ statusAltText = 'Active';
break;
default:
break;
}
+ const now = new Date();
let accessText = '';
- const currentDate = new Date();
-
- // Determine access text and override button text based on access dates.
- if (minDate && minDate > currentDate) {
- // Learning path will start in the future.
- const minDateStr = minDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
- accessText = <>Access starts on {minDateStr} >;
+ if (minDate && minDate > now) {
+ const d = minDate.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+ accessText = (
+ <>
+ Access starts on {d}
+ >
+ );
buttonText = 'View';
} else if (maxDate) {
- const maxDateStr = maxDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
- if (currentDate > maxDate) {
- // Learning path has ended.
- accessText = <>Access ended on {maxDateStr} >;
+ const d = maxDate.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+ if (now > maxDate) {
+ accessText = (
+ <>
+ Access ended on {d}
+ >
+ );
buttonText = 'View';
- // Remove status, as learners cannot do anything to change it at this point.
- if (status.toLowerCase() !== 'completed') {
- statusVariant = '';
- }
+ if (status.toLowerCase() !== 'completed') statusVariant = '';
} else {
- // Learning path is currently available.
- accessText = <>Access until {maxDateStr} >;
+ accessText = (
+ <>
+ Access until {d}
+ >
+ );
}
}
- const subtitleLine = subtitle && duration
- ? `${subtitle} • ${duration} days`
- : subtitle || duration || '';
- const { data: organizations = {} } = useOrganizations();
- const orgData = useMemo(() => ({
- name: organizations[org]?.name || org,
- logo: organizations[org]?.logo,
- }), [organizations, org]);
+ const subtitleLine =
+ subtitle && duration
+ ? `${subtitle} • ${duration} days`
+ : subtitle || duration || '';
+
+ const { partnerName, partnerLogo, partnerSlug } = useMemo(() => ({
+ partnerName: partner?.name || partner?.slug || '',
+ partnerLogo: partner?.logo,
+ partnerSlug: partner?.slug,
+ }), [partner]);
- const progressBarPercent = percent ? +percent.toFixed(1) : '0.0';
+ const learningPathUrl = partnerSlug
+ ? `/catalog/${partnerSlug}/${key}`
+ : `/catalog/${key}`;
return (
-
+
-
-
- LEARNING PATH
- {!!statusVariant && {status.toUpperCase()} }
-
- {displayName}
- {subtitleLine}
-
- {status.toLowerCase() === 'in progress' && !!statusVariant && (
-
- )}
-
-
-
- {numCourses && (
- {numCourses} courses
+
+
+
+ {/* Left section */}
+
+
+
{displayName}
+
+
+
+ {!!statusVariant && (
+
+ {statusAltText ?? status.toUpperCase()}
+
+ )}
+
+
+
+ {isManager && (
+
+ Manager
+
+ )}
+
+
+ {subtitleLine && (
+
{subtitleLine}
)}
- {accessText && (
-
{accessText}
+
+
+ {numCourses !== undefined && numCourses !== null && (
+
+ {numCourses} courses
+
+ )}
+ {accessText && (
+
+ {accessText}
+
+ )}
+
+
+
+ {/* Right section (actions) */}
+
+ {isManager && (
+
+
+ Manage
+
+
+
)}
-
-
-
- {buttonText}
+
+
+ {buttonText}
+
+
-
+
);
@@ -155,8 +218,14 @@ LearningPathCard.propTypes = {
status: PropTypes.string.isRequired,
minDate: PropTypes.instanceOf(Date),
maxDate: PropTypes.instanceOf(Date),
- percent: PropTypes.number,
- org: PropTypes.string,
+ partner: PropTypes.shape({
+ id: PropTypes.number,
+ slug: PropTypes.string.isRequired,
+ name: PropTypes.string,
+ homepageUrl: PropTypes.string,
+ logo: PropTypes.string,
+ }).isRequired,
+ isManager: PropTypes.bool,
}).isRequired,
showFilters: PropTypes.bool,
};
diff --git a/src/learningpath/LearningPathDetails.jsx b/src/learningpath/LearningPathDetails.jsx
index cdfdabab..25b513f1 100644
--- a/src/learningpath/LearningPathDetails.jsx
+++ b/src/learningpath/LearningPathDetails.jsx
@@ -1,34 +1,34 @@
import React, { useMemo, useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import {
- Row, Spinner, Nav, Icon, ModalLayer, Button, Chip, Card, Collapsible,
+ Row, Spinner, Nav, Icon, ModalLayer, Button, Chip, Card, Collapsible, Col,
} from '@openedx/paragon';
import {
Person,
Award,
Calendar,
- FormatListBulleted,
- AccessTimeFilled,
ChevronLeft,
+ BookOpen,
+ Check,
} from '@openedx/paragon/icons';
import {
useLearningPathDetail, useCoursesByIds, useEnrollLearningPath, useOrganizations,
} from './data/queries';
import CourseDetailPage from './CourseDetails';
+import DataSharingAuthorizationModal from './DataSharingAuthorizationModal';
import { CoursesWithProgressList } from './progress';
import { useScreenSize } from '../hooks/useScreenSize';
+import { buildCourseAboutUrl } from './utils';
const LearningPathDetailPage = () => {
const { isSmall } = useScreenSize();
- const { key } = useParams();
+ const { org, key } = useParams();
const [selectedCourseKey, setSelectedCourseKey] = useState(null);
const [enrolling, setEnrolling] = useState(false);
const [openCollapsible, setOpenCollapsible] = useState(null);
-
+ const [localStatus, setLocalStatus] = useState(null);
const [activeTab, setActiveTab] = useState(null);
- const handleTabSelect = (selectedKey) => {
- setActiveTab(selectedKey);
- };
+
const handleCollapsibleToggle = (collapsibleId) => {
setOpenCollapsible(openCollapsible === collapsibleId ? null : collapsibleId);
@@ -53,6 +53,29 @@ const LearningPathDetailPage = () => {
}
}, [detail, activeTab]);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+
+ const handleDoNotShare = () => {
+ setIsModalOpen(false);
+ };
+
+ const handleAllowAndContinue = async () => {
+ if (detail && !detail.enrollmentDate) {
+ setEnrolling(true);
+ setIsModalOpen(false);
+ try {
+ await enrollMutation.mutateAsync(key);
+ setLocalStatus('accepted');
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error('Enrollment failed:', error);
+ } finally {
+ setActiveTab('courses');
+ setEnrolling(false);
+ }
+ }
+ };
+
const courseIds = useMemo(() => (detail && detail.steps ? detail.steps.map(step => step.courseKey) : []), [detail]);
const {
@@ -83,7 +106,7 @@ const LearningPathDetailPage = () => {
// In the details view, open the course details modal.
const handleCourseViewButton = (courseId) => {
setTimeout(() => window.scrollTo({ top: 0, behavior: 'smooth' }), 10);
- setSelectedCourseKey(courseId);
+ window.open(buildCourseAboutUrl(courseId), '_blank', 'noopener,noreferrer');
};
const handleCloseCourseModal = () => {
@@ -91,27 +114,37 @@ const LearningPathDetailPage = () => {
};
const handleEnrollClick = async () => {
- if (detail && !detail.enrollmentDate) {
- setEnrolling(true);
- try {
- await enrollMutation.mutateAsync(key);
- } catch (error) {
- // eslint-disable-next-line no-console
- console.error('Enrollment failed:', error);
- } finally {
- setActiveTab('courses');
- setEnrolling(false);
- }
- }
+ setIsModalOpen(true);
};
// TODO: Retrieve this from the backend.
- const org = key.match(/path-v1:([^+]+)/)[1];
const { data: organizations = {} } = useOrganizations();
- const orgData = useMemo(() => ({
- name: organizations[org]?.name || org,
- logo: organizations[org]?.logo,
- }), [organizations, org]);
+
+ const orgData = useMemo(
+ () => ({
+ name: organizations[org]?.name || org,
+ logo: organizations[org]?.logo,
+ }),
+ [organizations, org],
+ );
+
+ const status = (localStatus || detail?.status || 'self enrollment').toLowerCase();
+ const isEnrolledInLearningPath = status === 'accepted';
+ let statusVariant = "pending";
+ let statusAltText = "Self Enrollment";
+
+ switch (status) {
+ case 'sent':
+ statusVariant = 'pending';
+ statusAltText = "Pending Invitation";
+ break;
+ case 'accepted':
+ statusVariant = 'accepted';
+ statusAltText = "Active";
+ break;
+ default:
+ break;
+ }
let content;
if (loadingDetail || loadingCourses) {
@@ -126,13 +159,13 @@ const LearningPathDetailPage = () => {
Failed to load detail
-
Go Back
+
Back to My Catalogs
);
} else {
const {
- displayName,
+ name,
image,
subtitle,
duration,
@@ -142,149 +175,148 @@ const LearningPathDetailPage = () => {
enrollmentDate,
} = detail;
- // Hero section - same for both full view and enrolled view.
- const heroSection = (
-
-
-
-
-
-
- Go Back
-
-
- {isSmall && (
-
- )}
-
- LEARNING PATH
- {displayName}
- {/* eslint-disable-next-line react/no-danger */}
-
-
+ const detailSection = (
+
+
+
+
+
+
+ Back to My Catalogs
+
+
+
+
+
+
+
+
+
+
+
+
+ {name}
+
+
+
+ {statusAltText && (
+
+ {statusAltText}
+
+ )}
+
+ {detail?.courses != null && (
+
+ {detail.courses} courses
+
+ )}
+
+
+
+
+ {status !== 'accepted' && (
+
+
+ {(() => {
+ if (enrolling) return 'Enrolling...';
+ if (enrollmentDate) return 'Enrolled';
+ if (status === 'sent') return 'Accept the invitation';
+ return "Self Enrollment"
+ })()}
+
+
+
+ )}
+
- {!isSmall && (
-
- )}
- {isSmall && (
-
-
- {(() => {
- if (enrolling) { return 'Enrolling...'; }
- if (enrollmentDate) { return 'Enrolled'; }
- return 'Enroll';
- })()}
-
-
- )}
-
+
+
{accessUntilDate && (
-
-
+
+
- {accessUntilDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
+ {accessUntilDate.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ })}
Access ends
-
+
)}
-
-
+
+
+
Certificate
-
Courses include certification
+
+ Courses include certification
+
-
-
-
+
+
+
+
{duration || 'Duration not available'}
-
{timeCommitment || 'Duration'}
+
+ {timeCommitment || 'Duration'}
+
-
-
-
+
+
+
+
Self-paced
-
Progress at your own speed
+
+ Progress at your own speed
+
-
+
- );
+
+ );
content = (
- {heroSection}
- {!isSmall && (
-
-
-
- About
-
-
- Courses
-
- {requiredSkills && requiredSkills.length > 0 && (
-
- Requirements
-
- )}
-
-
- {(() => {
- if (enrolling) { return 'Enrolling...'; }
- if (enrollmentDate) { return 'Enrolled'; }
- return 'Enroll';
- })()}
-
-
- )}
+ {detailSection}
{isSmall ? (
-
handleCollapsibleToggle('about')}
- className="mb-2"
- >
-
- {/* eslint-disable-next-line react/no-danger */}
-
-
-
-
{
)}
-
- {requiredSkills && requiredSkills.length > 0 && (
-
handleCollapsibleToggle('requirements')}
- className="mb-2"
- >
-
- {requiredSkills.map((skillObj) => (
-
- {skillObj.skill.displayName}
-
- ))}
-
-
- )}
) : (
- {activeTab === 'about' && (
-
- About
- {/* eslint-disable-next-line react/no-danger */}
-
-
- )}
- {activeTab === 'courses' && (
-
-
- Courses
- {!loadingCourses && !coursesError && (!coursesForPath || coursesForPath.length === 0) && (
- No sub-courses found in this learning path.
- )}
- {!loadingCourses && !coursesError && coursesForPath && coursesForPath.length > 0 && (
-
- )}
-
-
- )}
- {activeTab === 'requirements' && (
-
- Requirements
- {requiredSkills.map((skillObj) => (
-
- {skillObj.skill.displayName}
-
- ))}
+
+
+ {!loadingCourses && !coursesError && (!coursesForPath || coursesForPath.length === 0) && (
+ No sub-courses found in this learning path.
+ )}
+ {!loadingCourses && !coursesError && coursesForPath && coursesForPath.length > 0 && (
+
+ )}
- )}
+
)}
+
+
);
@@ -383,7 +386,7 @@ const LearningPathDetailPage = () => {
isModalView
courseKey={selectedCourseKey}
onClose={handleCloseCourseModal}
- learningPathTitle={detail?.displayName}
+ learningPathTitle={detail?.name}
/>
)}
diff --git a/src/learningpath/data/api.js b/src/learningpath/data/api.js
index f3585c48..383ebaeb 100644
--- a/src/learningpath/data/api.js
+++ b/src/learningpath/data/api.js
@@ -4,14 +4,15 @@ import { getConfig, camelCaseObject } from '@edx/frontend-platform';
export async function fetchLearningPaths() {
const client = getAuthenticatedHttpClient();
// FIXME: This API has pagination.
- const response = await client.get(`${getConfig().LMS_BASE_URL}/api/learning_paths/v1/learning-paths/`);
+ const response = await client.get(`${getConfig().LMS_BASE_URL}/partner_catalog/api/v1/catalogs/`);
const data = response.data.results || response.data;
+
return camelCaseObject(data);
}
export async function fetchLearningPathDetail(key) {
const client = getAuthenticatedHttpClient();
- const response = await client.get(`${getConfig().LMS_BASE_URL}/api/learning_paths/v1/learning-paths/${key}/`);
+ const response = await client.get(`${getConfig().LMS_BASE_URL}/partner_catalog/api/v1/catalogs/${key}/`);
return camelCaseObject(response.data);
}
@@ -110,7 +111,7 @@ export async function enrollInLearningPath(learningPathId) {
const client = getAuthenticatedHttpClient();
try {
const response = await client.post(
- `${getConfig().LMS_BASE_URL}/api/learning_paths/v1/${learningPathId}/enrollments/`,
+ `${getConfig().LMS_BASE_URL}/partner_catalog/api/v1/catalogs/${learningPathId}/enroll/`,
);
return {
success: true,
@@ -129,7 +130,7 @@ export async function enrollInCourse(learningPathId, courseId) {
const client = getAuthenticatedHttpClient();
try {
const response = await client.post(
- `${getConfig().LMS_BASE_URL}/api/learning_paths/v1/${learningPathId}/enrollments/${courseId}/`,
+ `${getConfig().LMS_BASE_URL}/partner_catalog/api/v1/catalogs/${learningPathId}/courses/${courseId}/enroll/`,
);
return {
success: true,
diff --git a/src/learningpath/data/queries.js b/src/learningpath/data/queries.js
index 354408a3..af0a7ca0 100644
--- a/src/learningpath/data/queries.js
+++ b/src/learningpath/data/queries.js
@@ -54,34 +54,23 @@ export const useLearningPaths = () => {
return learningPathList.map(lp => {
// Calculate progress based on course completions
- const totalCourses = lp.steps.length;
+ const totalCourses = lp.courses;
if (totalCourses === 0) {
return {
...lp,
numCourses: 0,
- status: 'Not started',
maxDate: null,
percent: 0,
type: 'learning_path',
};
}
- const totalCompletion = lp.steps.reduce((sum, step) => {
- const completion = completionsMap[step.courseKey];
- return sum + (completion?.percent ?? 0);
- }, 0);
+ const totalCompletion = 0;
const progress = totalCompletion / totalCourses;
const requiredCompletion = lp.requiredCompletion || 0;
- let status = 'In progress';
- if (progress === 0) {
- status = 'Not started';
- } else if (progress >= requiredCompletion) {
- status = 'Completed';
- }
-
let percent = 0;
if (requiredCompletion > 0) {
percent = Math.round((progress / requiredCompletion) * 100);
@@ -91,32 +80,17 @@ export const useLearningPaths = () => {
let minDate = null;
let maxDate = null;
- for (const course of lp.steps) {
- if (course.courseDates && course.courseDates.length > 0) {
- if (course.courseDates[0]) {
- const startDateObj = new Date(course.courseDates[0]);
- if (!minDate || startDateObj < minDate) {
- minDate = startDateObj;
- }
- }
- if (course.courseDates[1]) {
- const endDateObj = new Date(course.courseDates[1]);
- if (!maxDate || endDateObj > maxDate) {
- maxDate = endDateObj;
- }
- }
- }
- }
return {
...lp,
+ key: lp.id,
+ displayName: lp.name,
numCourses: totalCourses,
- status,
minDate,
maxDate,
percent,
type: 'learning_path',
- org: lp.key.match(/path-v1:([^+]+)/)[1],
+ org: lp.org,
enrollmentDate: lp.enrollmentDate ? new Date(lp.enrollmentDate) : null,
};
});
@@ -266,7 +240,6 @@ export const useCoursesByIds = (courseIds) => {
type: 'course',
org: courseId ? courseId.match(/course-v1:([^+]+)/)?.[1] : null,
});
-
return {
...addCompletionStatus(detail, completionsMap, courseId),
type: 'course',
@@ -274,7 +247,6 @@ export const useCoursesByIds = (courseIds) => {
};
}),
);
-
return results;
},
enabled: courseIds && courseIds.length > 0,
diff --git a/src/learningpath/index.css b/src/learningpath/index.css
index 02eb8778..5767e079 100644
--- a/src/learningpath/index.css
+++ b/src/learningpath/index.css
@@ -148,10 +148,6 @@
border-top: 5px solid #8C8179 !important;
}
- &.lp-card .pgn__card-body {
- border-top: 5px solid var(--crimson) !important;
- }
-
.pgn__card-wrapper-image-cap {
.pgn__card-image-cap {
/* The default (`auto`) value does not work in Safari. */
@@ -159,7 +155,6 @@
}
.pgn__card-logo-cap {
- top: 1rem;
left: 1rem !important;
}
}
@@ -372,21 +367,28 @@
&.status-chip {
border: 0;
-
- &.status-secondary {
- background-color: #8996A0;
+
+ .pgn__chip__label,
+ .pgn__icon {
+ color: white !important;
+ font-weight: 400;
}
+ &.status-pending {
+ background-color: #FEBE46B2;
+
+ .pgn__chip__label,
+ .pgn__icon {
+ color: black !important;
+ }
+ }
+
&.status-info {
background-color: #05A59D;
}
-
- &.status-success {
- background-color: #52854C;
- }
-
- .pgn__chip__label, .pgn__icon {
- color: white !important;
+
+ &.status-accepted {
+ background-color: #029550;
}
}
@@ -587,7 +589,7 @@
.lp-info {
.desktop-content section {
- padding: 5rem 10rem;
+ padding: 1rem 10rem;
h2 {
font-size: 1.5rem;
@@ -603,11 +605,6 @@
}
}
- #courses-section-wrapper {
- section {
- max-width: 1320px;
- }
- }
}
}
diff --git a/src/learningpath/progress/CourseWithProgress.tsx b/src/learningpath/progress/CourseWithProgress.tsx
index 3440cffd..6f0e83ae 100644
--- a/src/learningpath/progress/CourseWithProgress.tsx
+++ b/src/learningpath/progress/CourseWithProgress.tsx
@@ -6,39 +6,26 @@ import { Course } from './types';
interface CourseWithProgressProps {
course: Course;
learningPathId: string;
- enrollmentDateInLearningPath?: string | null;
+ isEnrolledInLearningPath?: boolean | false;
onCourseClick: () => void;
}
const CourseWithProgress: React.FC = ({
course,
learningPathId,
- enrollmentDateInLearningPath,
+ isEnrolledInLearningPath,
onCourseClick,
}) => (
);
diff --git a/src/learningpath/progress/CoursesWithProgressList.tsx b/src/learningpath/progress/CoursesWithProgressList.tsx
index dc84183f..2d51f3cd 100644
--- a/src/learningpath/progress/CoursesWithProgressList.tsx
+++ b/src/learningpath/progress/CoursesWithProgressList.tsx
@@ -1,61 +1,37 @@
import React from 'react';
+import { Container, Row, Col } from '@openedx/paragon';
import CourseWithProgress from './CourseWithProgress';
-import ProgressIndicator from './ProgressIndicator';
import { Course } from './types';
-interface CompletionMessageProps {
- completed: 'Completed' | 'Not started';
-}
-
-const CompletionMessage: React.FC = ({ completed }) => (
-
-
-
-
-
- Congratulations!
-
-
- You've completed the Learning Path. We can't wait to see where these skills take you next.
-
-
-
-
-);
-
interface CoursesWithProgressListProps {
courses?: Course[];
learningPathId: string;
- enrollmentDateInLearningPath?: string | null;
+ isEnrolledInLearningPath?: boolean | false;
onCourseClick: (courseId: string) => void;
}
const CoursesWithProgressList: React.FC = ({
courses = [],
learningPathId,
- enrollmentDateInLearningPath = null,
+ isEnrolledInLearningPath = false,
onCourseClick,
}) => {
- const finalIndicatorStatus: 'Completed' | 'Not started' = courses.every(course => course.status.toLowerCase() === 'completed') ? 'Completed' : 'Not started';
return (
-
+
+
{courses.map((course) => (
-
+
onCourseClick(course.id)}
/>
-
+
))}
-
-
-
-
+
+
);
};
diff --git a/src/learningpath/progress/ProgressIndicator.tsx b/src/learningpath/progress/ProgressIndicator.tsx
index ab157729..d7944ad8 100644
--- a/src/learningpath/progress/ProgressIndicator.tsx
+++ b/src/learningpath/progress/ProgressIndicator.tsx
@@ -20,13 +20,13 @@ const ProgressIndicator: React.FC = ({ status }) => {
return {
icon: Timelapse,
color: 'var(--m-teal)',
- altText: 'In progress',
+ altText: 'Accepted',
};
default:
return {
icon: LmsCompletionSolid,
color: '#8996A0',
- altText: 'Not started',
+ altText: 'Sent',
};
}
};
diff --git a/src/learningpath/progress/types.ts b/src/learningpath/progress/types.ts
index e4eb1361..24092009 100644
--- a/src/learningpath/progress/types.ts
+++ b/src/learningpath/progress/types.ts
@@ -1,4 +1,4 @@
-export type ProgressStatus = 'Completed' | 'In progress' | 'Not started';
+export type ProgressStatus = 'Completed' | 'Accepted' | 'Sent';
export interface Course {
id: string;
diff --git a/src/learningpath/utils.js b/src/learningpath/utils.js
index e18f8113..626eefe7 100644
--- a/src/learningpath/utils.js
+++ b/src/learningpath/utils.js
@@ -13,3 +13,14 @@ export const buildCourseHomeUrl = (courseId) => {
: `${trimmedBase}/learning`;
return `${sanitizedBase}/course/${courseId}/home`;
};
+
+export const buildMarketingSiteCourseUrl = (courseId) => {
+ const marketingSiteBase = getConfig().MARKETING_SITE_BASE_URL;
+ return `${marketingSiteBase}/${courseId}`
+};
+
+export const buildCourseAboutUrl = (courseId) => {
+ const lmsBaseUrl = getConfig().LMS_BASE_URL;
+ const trimmedBase = lmsBaseUrl.replace(/\/$/, '');
+ return `${trimmedBase}/courses/${courseId}/about`;
+}
\ No newline at end of file