diff --git a/src/Router.tsx b/src/Router.tsx index fd874c4..136930a 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -7,24 +7,23 @@ import { CatalogEditionModalProvider } from './catalogs/useCatalogEditionModal'; const CorporatePartnerPage = lazy(() => import('@src/partner/CorporatePartnerPage')); const PartnerCatalogsPage = lazy(() => import('@src/catalogs/PartnerCatalogsPage')); const CoursesPage = lazy(() => import('@src/courses/CoursesPage')); +const CourseEnrollmentsPage = lazy(() => import('@src/enrollments/CourseEnrollmentsPage')); const Router = () => ( Loading...}> - - - - + + + + - - -

Course Details

-
- -

404 Not Found

-
-
-
+ + +

404 Not Found

+
+ + +
); diff --git a/src/app/ImageWithSkeleton.tsx b/src/app/ImageWithSkeleton.tsx index e80f7b9..9b60818 100644 --- a/src/app/ImageWithSkeleton.tsx +++ b/src/app/ImageWithSkeleton.tsx @@ -22,7 +22,7 @@ const ImageWithSkeleton: FC = ({ onLoad={() => setImageLoaded(true)} className={className ?? ''} style={{ - maxHeight: height, width, display: isImageLoaded ? 'block' : 'none', objectFit: 'cover', + maxHeight: height, width, display: isImageLoaded ? 'block' : 'none', objectFit: 'contain', }} /> {!isImageLoaded && } diff --git a/src/courses/api.ts b/src/courses/api.ts index bcbfca0..cf56aef 100644 --- a/src/courses/api.ts +++ b/src/courses/api.ts @@ -3,12 +3,19 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { logError } from '@edx/frontend-platform/logging'; import { CorporateCourse, PaginatedResponse } from '@src/app/types'; -export const getCourses = async (partnerId: string, catalogId: string, pageIndex, pageSize) -: Promise> => { +export const getCourses = async ( + partnerId: string, + catalogId: string, + pageIndex?: number, + pageSize?: number, + courseOverview?: string, +): Promise> => { try { const url = new URL(`${getConfig().LMS_BASE_URL}/corporate_access/api/v1/partners/${partnerId}/catalogs/${catalogId}/courses/`); - url.searchParams.append('page', pageIndex); - url.searchParams.append('page_size', pageSize); + if (courseOverview) { url.searchParams.append('course_overview', courseOverview); } + if (pageIndex) { url.searchParams.append('page', String(pageIndex)); } + if (pageSize) { url.searchParams.append('page_size', String(pageSize)); } + const { data } = await getAuthenticatedHttpClient().get(url); return camelCaseObject(data); } catch (error) { @@ -27,3 +34,14 @@ export const deleteCourse = async (partnerId: string, catalogId: string, courseI throw error; } }; + +export const getCourseDetails = async (partnerId: string, catalogId: string, courseId: number) +: Promise => { + try { + const { data } = await getAuthenticatedHttpClient().get(`${getConfig().LMS_BASE_URL}/corporate_access/api/v1/partners/${partnerId}/catalogs/${catalogId}/courses/${courseId}/`); + return camelCaseObject(data); + } catch (error) { + logError(error); + return null; + } +}; diff --git a/src/courses/components/CoursesList.tsx b/src/courses/components/CoursesList.tsx index 5502d3a..f21e2d3 100644 --- a/src/courses/components/CoursesList.tsx +++ b/src/courses/components/CoursesList.tsx @@ -30,7 +30,9 @@ const CoursesList = ({ partnerId, catalogId }: CoursesListProps) => { count, pageCount, isLoading, - } = useCatalogCourses(partnerId, catalogId, pageIndex + 1, pageSize); + } = useCatalogCourses({ + partnerId, catalogId, pageIndex: pageIndex + 1, pageSize, + }); const deleteCatalogCourse = useDeleteCatalogCourse(); const positions = Array.from({ length: count + 1 || 0 }, (_, i) => i); diff --git a/src/courses/hooks.test.tsx b/src/courses/hooks.test.tsx index 34c06e7..18c35f4 100644 --- a/src/courses/hooks.test.tsx +++ b/src/courses/hooks.test.tsx @@ -33,7 +33,9 @@ describe('useCatalogCourses', () => { }); const { result } = renderHook( - () => useCatalogCourses('p1', 'c1', 1, 10), + () => useCatalogCourses({ + partnerId: 'p1', catalogId: 'c1', pageIndex: 1, pageSize: 10, + }), { wrapper: createWrapper() }, ); @@ -46,7 +48,9 @@ describe('useCatalogCourses', () => { it('returns loading state', async () => { (api.getCourses as jest.Mock).mockImplementation(() => new Promise(() => {})); - const { result } = renderHook(() => useCatalogCourses('p1', 'c1', 1, 10), { + const { result } = renderHook(() => useCatalogCourses({ + partnerId: 'p1', catalogId: 'c1', pageIndex: 1, pageSize: 10, + }), { wrapper: createWrapper(), }); @@ -61,7 +65,9 @@ describe('useCatalogCourses', () => { }); const { result } = renderHook( - () => useCatalogCourses('p1', 'c1', 1, 10), + () => useCatalogCourses({ + partnerId: 'p1', catalogId: 'c1', pageIndex: 1, pageSize: 10, + }), { wrapper: createWrapper() }, ); @@ -79,7 +85,9 @@ describe('useCatalogCourses', () => { }); const { result } = renderHook( - () => useCatalogCourses('p1', 'c1', 1, 10), + () => useCatalogCourses({ + partnerId: 'p1', catalogId: 'c1', pageIndex: 1, pageSize: 10, + }), { wrapper: createWrapper() }, ); @@ -91,7 +99,9 @@ describe('useCatalogCourses', () => { (api.getCourses as jest.Mock).mockRejectedValue(new Error('API error')); const { result } = renderHook( - () => useCatalogCourses('p1', 'c1', 1, 10), + () => useCatalogCourses({ + partnerId: 'p1', catalogId: 'c1', pageIndex: 1, pageSize: 10, + }), { wrapper: createWrapper() }, ); diff --git a/src/courses/hooks.ts b/src/courses/hooks.ts index bdd7ce0..4549a21 100644 --- a/src/courses/hooks.ts +++ b/src/courses/hooks.ts @@ -1,14 +1,31 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { getCourses, deleteCourse } from './api'; +import { CorporateCourse, PaginatedResponse } from '@src/app/types'; +import { getCourses, deleteCourse, getCourseDetails } from './api'; + +export const useCatalogCourses = ({ + partnerId, catalogId, pageIndex, pageSize, courseOverview, +} : { + partnerId: string, + catalogId: string, + pageIndex?: number, + pageSize?: number, + courseOverview?: string, +}) => { + const queryClient = useQueryClient(); + + const courseCached = queryClient + .getQueriesData>({ queryKey: ['catalogCourses'] }) + .flatMap(([, data]) => data?.results ?? []) + .filter((course) => course.courseRun.id === courseOverview); -export const useCatalogCourses = (partnerId: string, catalogId: string, pageIndex, pageSize) => { const { data, isLoading } = useQuery({ - queryKey: ['catalogCourses', partnerId, catalogId, pageIndex, pageSize], - queryFn: () => getCourses(partnerId, catalogId, pageIndex, pageSize), + queryKey: ['catalogCourses', partnerId, catalogId, pageIndex, pageSize, courseOverview], + queryFn: () => getCourses(partnerId, catalogId, pageIndex, pageSize, courseOverview), + enabled: !courseCached.length, }); return { - courses: data?.results || [], count: data?.count || 0, pageCount: data?.numPages, isLoading, + courses: data?.results || courseCached, count: data?.count || 0, pageCount: data?.numPages, isLoading, }; }; @@ -26,3 +43,25 @@ export const useDeleteCatalogCourse = () => { }); return mutateAsync; }; + +export const useCourseDetails = ( + { partnerId, catalogId, courseId } : { partnerId: string; catalogId: string; courseId?: number }, +) => { + const queryClient = useQueryClient(); + + const allCourses = queryClient + .getQueriesData>({ queryKey: ['catalogCourses'] }) + .flatMap(([, data]) => data?.results ?? []); + const courseCached: CorporateCourse | undefined = allCourses.find((course) => course.id === Number(courseId)); + + const { data, isLoading } = useQuery({ + queryKey: ['courseDetails', partnerId, catalogId, courseId], + queryFn: () => getCourseDetails(partnerId, catalogId, courseId!), + enabled: !courseCached && !!courseId, + }); + + return { + courseDetails: courseCached || data, + isLoadingCourseDetails: isLoading, + }; +}; diff --git a/src/enrollments/CourseEnrollmentsPage.tsx b/src/enrollments/CourseEnrollmentsPage.tsx new file mode 100644 index 0000000..2877d7c --- /dev/null +++ b/src/enrollments/CourseEnrollmentsPage.tsx @@ -0,0 +1,43 @@ +import { useParams } from 'wouter'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { usePartnerDetails } from '@src/partner/hooks'; +import HeaderDescription from '@src/app/HeaderDescription'; +import { paths } from '@src/constants'; +import { useCatalogCourses, useCourseDetails } from '@src/courses/hooks'; +import AppLayout from '../app/AppLayout'; +import CourseEnrollmentsList from './components/CourseEnrollmentsList'; +import messages from './messages'; + +const CourseEnrollmentsPage = () => { + const intl = useIntl(); + const { partnerId, catalogId, courseId } = useParams<{ partnerId: string, catalogId: string, courseId: string }>(); + + const { courses } = useCatalogCourses({ partnerId, catalogId, courseOverview: courseId }); + + const courseCatalogPK = courses.find((course) => course.courseRun.id === courseId)?.id; + + const { partnerDetails } = usePartnerDetails({ partnerId }); + const { courseDetails } = useCourseDetails({ partnerId, catalogId, courseId: courseCatalogPK }); + + return ( + + {courseDetails && ( + + )} + + + ); +}; + +export default CourseEnrollmentsPage; diff --git a/src/enrollments/api.ts b/src/enrollments/api.ts new file mode 100644 index 0000000..1a48622 --- /dev/null +++ b/src/enrollments/api.ts @@ -0,0 +1,34 @@ +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { logError } from '@edx/frontend-platform/logging'; +import { Learner, PaginatedResponse } from '../app/types'; + +export const getCourseEnrollments = async ({ + partnerId, catalogId, courseId, pageIndex, pageSize, +}:{ + partnerId: string, + catalogId: string, + courseId: number, + pageIndex?: number, + pageSize?: number, +}): Promise> => { + try { + const url = new URL(`${getConfig().LMS_BASE_URL}/corporate_access/api/v1/partners/${partnerId}/catalogs/${catalogId}/courses/${courseId}/enrollments/`); + if (pageIndex) { url.searchParams.append('page', String(pageIndex)); } + if (pageSize) { url.searchParams.append('page_size', String(pageSize)); } + + const response = await getAuthenticatedHttpClient().get(url); + return camelCaseObject(response.data); + } catch (error) { + logError(error); + return { + next: null, + previous: null, + count: 0, + numPages: 0, + currentPage: 0, + start: 0, + results: [], + }; + } +}; diff --git a/src/enrollments/components/CourseEnrollmentsList.tsx b/src/enrollments/components/CourseEnrollmentsList.tsx new file mode 100644 index 0000000..01298ad --- /dev/null +++ b/src/enrollments/components/CourseEnrollmentsList.tsx @@ -0,0 +1,71 @@ +import { FC } from 'react'; +import { useParams } from 'wouter'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + DataTable, TextFilter, +} from '@openedx/paragon'; + +import { usePagination } from '@src/hooks'; +import TableFooter from '@src/app/TableFooter'; + +import messages from '../messages'; +import { useCatalogCourseEnrollments } from '../hooks'; + +const CourseEnrollmentsList: FC<{ courseCatalogPK: number | undefined }> = ({ courseCatalogPK }) => { + const intl = useIntl(); + + const { partnerId, catalogId } = useParams(); + const { pageSize, pageIndex, onPaginationChange } = usePagination(); + + const { + enrollments, count, pageCount, isLoading, + } = useCatalogCourseEnrollments({ + partnerId: partnerId!, catalogId: catalogId!, courseId: courseCatalogPK, pageIndex: pageIndex + 1, pageSize, + }); + + return ( + `${value}%`, + }, + ]} + > + + + + + ); +}; + +export default CourseEnrollmentsList; diff --git a/src/enrollments/hooks.ts b/src/enrollments/hooks.ts new file mode 100644 index 0000000..d231aa6 --- /dev/null +++ b/src/enrollments/hooks.ts @@ -0,0 +1,24 @@ +import { useQuery } from '@tanstack/react-query'; +import { getCourseEnrollments } from './api'; + +export const useCatalogCourseEnrollments = ({ + partnerId, catalogId, pageIndex, pageSize, courseId, +} : { + partnerId: string, + catalogId: string, + pageIndex?: number, + pageSize?: number, + courseId?: number, +}) => { + const { data, isLoading } = useQuery({ + queryKey: ['courseLearners', courseId], + queryFn: () => getCourseEnrollments({ + partnerId, catalogId, courseId: courseId!, pageIndex, pageSize, + }), + enabled: !!courseId, + }); + + return { + enrollments: data?.results || [], count: data?.count || 0, pageCount: data?.numPages, isLoading, + }; +}; diff --git a/src/enrollments/messages.js b/src/enrollments/messages.js new file mode 100644 index 0000000..a528ec5 --- /dev/null +++ b/src/enrollments/messages.js @@ -0,0 +1,51 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + titleCorporatePage: { + id: 'course.enrollments.page.title', + defaultMessage: 'Course Enrollments', + description: 'Page title for the course enrollments page', + }, + headerStudentName: { + id: 'course.enrollments.table.header.student_name', + defaultMessage: 'Student Name', + description: 'Header for the student name column', + }, + headerEmail: { + id: 'course.enrollments.table.header.email', + defaultMessage: 'Email', + description: 'Header for the email column', + }, + headerCompletedAssessments: { + id: 'course.enrollments.table.header.completed_assessments', + defaultMessage: 'Completed Assessments', + description: 'Header for the completed assessments column', + }, + headerDueAssessments: { + id: 'course.enrollments.table.header.due_assessments', + defaultMessage: 'Assessments to be done', + description: 'Header for the due assessments column', + }, + headerProgress: { + id: 'course.enrollments.table.header.progress', + defaultMessage: 'Progress', + description: 'Header for the progress column', + }, + infoEnrollments: { + id: 'course.enrollments.info.enrollments', + defaultMessage: 'Enrollments', + description: 'Info for the number of enrollments', + }, + infoCertified: { + id: 'course.enrollments.info.certified', + defaultMessage: 'Certified students', + description: 'Info for the number of certified students', + }, + infoCompletion: { + id: 'course.enrollments.info.completion', + defaultMessage: 'Completion rate', + description: 'Info for the completion rate', + }, +}); + +export default messages;