Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 12 additions & 13 deletions src/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => (
<Suspense fallback={<div>Loading...</div>}>
<WouterRouter base={paths.base}>
<Switch>
<Route path={paths.partners.path} component={CorporatePartnerPage} />
<CatalogEditionModalProvider>
<CatalogEditionModalProvider>
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is done because there were unknown issues with accessing the paths out of the context

<WouterRouter base={paths.base}>
<Switch>
<Route path={paths.partners.path} component={CorporatePartnerPage} />
<Route path={paths.catalogs.path} component={PartnerCatalogsPage} />
<Route path={paths.courses.path} component={CoursesPage} />
</CatalogEditionModalProvider>
<Route path={paths.courseDetail.path}>
<h1>Course Details</h1>
</Route>
<Route>
<h1>404 Not Found</h1>
</Route>
</Switch>
</WouterRouter>
<Route path={paths.courseDetail.path} component={CourseEnrollmentsPage} />
<Route>
<h1>404 Not Found</h1>
</Route>
</Switch>
</WouterRouter>
</CatalogEditionModalProvider>
</Suspense>
);

Expand Down
2 changes: 1 addition & 1 deletion src/app/ImageWithSkeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const ImageWithSkeleton: FC<ImageWithSkeletonProps> = ({
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 && <Skeleton width={width} height={height} />}
Expand Down
26 changes: 22 additions & 4 deletions src/courses/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PaginatedResponse<CorporateCourse>> => {
export const getCourses = async (
partnerId: string,
catalogId: string,
pageIndex?: number,
pageSize?: number,
courseOverview?: string,
): Promise<PaginatedResponse<CorporateCourse>> => {
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) {
Expand All @@ -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<CorporateCourse | null> => {
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;
}
};
4 changes: 3 additions & 1 deletion src/courses/components/CoursesList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
20 changes: 15 additions & 5 deletions src/courses/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
);

Expand All @@ -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(),
});

Expand All @@ -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() },
);

Expand All @@ -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() },
);

Expand All @@ -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() },
);

Expand Down
49 changes: 44 additions & 5 deletions src/courses/hooks.ts
Original file line number Diff line number Diff line change
@@ -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<PaginatedResponse<CorporateCourse>>({ 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,
};
};

Expand All @@ -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<PaginatedResponse<CorporateCourse>>({ 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,
};
};
43 changes: 43 additions & 0 deletions src/enrollments/CourseEnrollmentsPage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AppLayout withBackButton backPath={paths.courses.buildPath(partnerId, catalogId)}>
{courseDetails && (
<HeaderDescription
context={{
title: courseDetails.courseRun.displayName,
imageUrl: partnerDetails?.logo || null,
description: courseDetails.courseRun.id,
}}
info={[
{ title: intl.formatMessage(messages.infoEnrollments), value: courseDetails.enrollments },
{ title: intl.formatMessage(messages.infoCertified), value: courseDetails.certified },
{ title: intl.formatMessage(messages.infoCompletion), value: `${courseDetails.completionRate}%` },
]}
/>
)}
<CourseEnrollmentsList courseCatalogPK={courseCatalogPK} />
</AppLayout>
);
};

export default CourseEnrollmentsPage;
34 changes: 34 additions & 0 deletions src/enrollments/api.ts
Original file line number Diff line number Diff line change
@@ -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<PaginatedResponse<Learner>> => {
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: [],
};
}
};
71 changes: 71 additions & 0 deletions src/enrollments/components/CourseEnrollmentsList.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<DataTable
isLoading={isLoading || !courseCatalogPK}
isPaginated
isFilterable
defaultColumnValues={{ Filter: TextFilter }}
fetchData={onPaginationChange}
initialState={{
pageSize: 30,
pageIndex: 0,
}}
itemCount={count}
pageCount={pageCount}
data={enrollments}
columns={[
{
Header: intl.formatMessage(messages.headerStudentName),
accessor: 'user.username',
},
{
Header: intl.formatMessage(messages.headerEmail),
accessor: 'user.email',
},
{
Header: intl.formatMessage(messages.headerCompletedAssessments),
accessor: 'completedAssessments',
},
{
Header: intl.formatMessage(messages.headerDueAssessments),
accessor: 'pendingAssessments',
},
{
Header: intl.formatMessage(messages.headerProgress),
accessor: 'progress',
Cell: ({ cell: { value } }) => `${value}%`,
},
]}
>
<DataTable.TableControlBar />
<DataTable.Table />
<TableFooter />
</DataTable>
);
};

export default CourseEnrollmentsList;
24 changes: 24 additions & 0 deletions src/enrollments/hooks.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
Loading
Loading