diff --git a/src/index.jsx b/src/index.jsx index 68edd322..7a302240 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -34,7 +34,7 @@ subscribe(APP_READY, () => { element={} /> } /> { const { name, org, courseImageAssetPath, startDate, - endDate, status, - percent, checkingEnrollment, } = course; - const { administrator } = getAuthenticatedUser(); + const dateDisplay = startDate + ? new Date(startDate).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + }) + : null; + + const { isSmall, isMedium } = useScreenSize(); - const orientation = (showFilters && (isSmall || isMedium)) || (!showFilters && isSmall) ? 'vertical' : 'horizontal'; + const orientation = orientationOverride + ? orientationOverride + : (showFilters && (isSmall || isMedium)) || (!showFilters && isSmall) + ? 'vertical' + : 'horizontal'; // Prefetch the course detail when the user hovers over the card. const prefetchCourseDetail = usePrefetchCourseDetail(course.id); @@ -44,27 +51,19 @@ export const CourseCard = ({ prefetchCourseDetail(); }; - const progressBarPercent = useMemo(() => +(percent * 100).toFixed(1), [percent]); - const linkTo = buildCourseHomeUrl(course.id); - let statusVariant = 'dark'; // default - let statusIcon = 'fa-circle'; // default icon - let buttonText = 'View'; + let buttonText = 'Start Course'; + switch (status?.toLowerCase()) { case 'completed': - statusVariant = 'success'; - statusIcon = CheckCircle; + buttonText = 'View Certificate'; break; case 'not started': - statusVariant = 'secondary'; - statusIcon = LmsCompletionSolid; - buttonText = 'Start'; + buttonText = 'Start Course'; break; case 'in progress': - statusVariant = 'info'; - statusIcon = Timelapse; - buttonText = 'Resume'; + buttonText = 'Continue'; break; default: break; @@ -74,37 +73,7 @@ export const CourseCard = ({ buttonText = 'Loading...'; } - const disableStartButton = !administrator && (checkingEnrollment || isEnrolledInLearningPath === false); - let showStartButton = true; - - let accessText = ''; - const currentDate = new Date(); - - const startDateObj = startDate ? new Date(startDate) : null; - const endDateObj = endDate ? new Date(endDate) : null; - - // Determine access text and override button text based on access dates. - if (startDateObj && startDateObj > currentDate) { - // Course will start in the future. - const startDateStr = startDateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); - accessText = <>Access starts on {startDateStr}; - buttonText = 'Start'; - showStartButton = administrator; - } else if (endDateObj) { - const endDateStr = endDateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); - if (currentDate > endDateObj) { - // Course has ended. - accessText = <>Access ended on {endDateStr}; - buttonText = 'View'; - // Remove status, as learners cannot do anything to change it at this point. - if (status.toLowerCase() !== 'completed') { - statusVariant = ''; - } - } else { - // Course is currently available. - accessText = <>Access until {endDateStr}; - } - } + const disableStartButton = checkingEnrollment || isEnrolledInLearningPath === false; const { data: organizations = {} } = useOrganizations(); const orgData = useMemo(() => ({ @@ -114,70 +83,90 @@ export const CourseCard = ({ return ( <> - + - - - COURSE - {!!statusVariant && {status.toUpperCase()}} - -

{name}

- - {status.toLowerCase() === 'in progress' && !!statusVariant && ( - + + +
+ {/* Enrolled Count */} +
+ - )} - - - - {accessText && ( - {accessText} - )} - -
- {onClickViewButton && ( - - )} - {showStartButton && ( - onClick ? ( - - ) : ( - - - - ) - )} + 10 Enrolled
-
- - - {relatedLearningPaths && relatedLearningPaths.length > 0 && ( - -
-
- -

Related Learning Path{relatedLearningPaths.length > 1 ? 's' : ''}:

+ + {/* Duration */} +
+ + 20 Hours +
+ + {/* Start Date */} +
+ + Starts {dateDisplay}
-
    - {relatedLearningPaths.map((learningPath) => ( -
  • - {learningPath.name} -
  • - ))} -
- - )} + + + +
+ + + {!disableStartButton && ( + + )} +
+
+ ); }; @@ -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 && ( - )} @@ -345,7 +377,9 @@ const Dashboard = () => { Showing {showingCount} of {totalCount}
+
+ {sortedItems.length === 0 ? (
No results @@ -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}? +

+
+
+ + + + + + + ); +} + +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 && ( - + )}
- {/* Content Type Tabs */} -
- - - - - -
- - {/* 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 && ( - - + + )}
); 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 && ( + + + )} - -
- - + +
- +
); @@ -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' && ( +
+ +
+ )} +
- {!isSmall && ( - - )} - {isSmall && ( -
- -
- )} - + + {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 && ( -
- - -
- )} + {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