diff --git a/src/enrollments/EnrollmentsPage.tsx b/src/enrollments/EnrollmentsPage.tsx new file mode 100644 index 0000000..9116fb4 --- /dev/null +++ b/src/enrollments/EnrollmentsPage.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react'; +import { useIntl } from '@openedx/frontend-base'; +import { ActionRow, Button, IconButton } from '@openedx/paragon'; +import { MoreVert } from '@openedx/paragon/icons'; +import messages from './messages'; +import EnrollmentsList from './components/EnrollmentsList'; +import EnrollmentStatusModal from './components/EnrollmentStatusModal'; +import UnenrollModal from './components/UnenrollModal'; +import EnrollLearnersModal from './components/EnrollLearnersModal'; +import { Learner } from './types'; + +const EnrollmentsPage = () => { + const intl = useIntl(); + const [isEnrollmentStatusModalOpen, setIsEnrollmentStatusModalOpen] = useState(false); + const [isEnrollLearnersModalOpen, setIsEnrollLearnersModalOpen] = useState(false); + const [isUnenrollModalOpen, setIsUnenrollModalOpen] = useState(false); + const [selectedLearner, setSelectedLearner] = useState(null); + + const handleMoreButton = () => { + setIsEnrollmentStatusModalOpen(true); + }; + + const handleUnenroll = (learner: Learner) => { + setIsUnenrollModalOpen(true); + setSelectedLearner(learner); + }; + + const handleUnenrollModalClose = () => { + setIsUnenrollModalOpen(false); + setSelectedLearner(null); + }; + + const handleCloseEnrollmentStatusModal = () => { + setIsEnrollmentStatusModalOpen(false); + }; + + const handleEnrollLearners = () => { + setIsEnrollLearnersModalOpen(true); + }; + + return ( +
+
+

{intl.formatMessage(messages.enrollmentsPageTitle)}

+ + + + + +
+ + + + setIsEnrollLearnersModalOpen(false)} onSuccess={() => {}} /> + setIsAddBetaTestersModalOpen(false)} onSuccess={() => {}} /> +
+ ); +}; + +export default EnrollmentsPage; diff --git a/src/enrollments/components/AddModal.tsx b/src/enrollments/components/AddModal.tsx new file mode 100644 index 0000000..e405c21 --- /dev/null +++ b/src/enrollments/components/AddModal.tsx @@ -0,0 +1,66 @@ +import { useState } from 'react'; +import { useIntl } from '@openedx/frontend-base'; +import { Button, FormControl, ModalDialog } from '@openedx/paragon'; +import messages from '../messages'; +import { FormCheckbox, FormCheckboxSet } from '@openedx/paragon/dist/Form'; + +export interface AddModalProps { + instructions: string, + isOpen: boolean, + title: string, + onClose: () => void, + onSave: (emailList: string[]) => void, +} + +const AddModal = ({ + instructions, + isOpen, + title, + onClose, + onSave }: AddModalProps) => { + const intl = useIntl(); + const [emails, setEmails] = useState(''); + + const handleSave = () => { + const emailList = emails.split(',').map(email => email.trim()).filter(email => email); + onSave(emailList); + }; + + return ( + + +

{title}

+
+ + {/* TABS will be added as a follow up */} + {/* {}}> + */} +

{instructions}

+ setEmails(e.target.value)} + /> + + {intl.formatMessage(messages.autoEnrollCheckbox)} + {intl.formatMessage(messages.notifyUsersCheckbox)} + + {/*
+ + +
*/} +
+ + + + +
+ ); +}; + +export default AddModal; diff --git a/src/enrollments/components/EnrollLearnersModal.tsx b/src/enrollments/components/EnrollLearnersModal.tsx new file mode 100644 index 0000000..01a65b9 --- /dev/null +++ b/src/enrollments/components/EnrollLearnersModal.tsx @@ -0,0 +1,40 @@ +import { useParams } from 'react-router-dom'; +import { useEnrollLearners } from '../data/apiHook'; +import AddModal from './AddModal'; +import { useIntl } from '@openedx/frontend-base'; +import messages from '../messages'; + +export interface EnrollLearnersModalProps { + isOpen: boolean, + onClose: () => void, + onSuccess: () => void, +} + +const EnrollLearnersModal = ({ isOpen, onClose, onSuccess }: EnrollLearnersModalProps) => { + const intl = useIntl(); + const { courseId = '' } = useParams<{ courseId: string }>(); + const { mutate: enrollLearners } = useEnrollLearners(courseId); + + const handleEnroll = (emailList: string[]) => { + enrollLearners(emailList, { + onSuccess: () => { + onSuccess(); + onClose(); + }, + onError: (error) => { + console.error(error); + } + }); + }; + return ( + + ); +}; + +export default EnrollLearnersModal; diff --git a/src/enrollments/components/EnrollmentStatusModal.tsx b/src/enrollments/components/EnrollmentStatusModal.tsx new file mode 100644 index 0000000..a5cd692 --- /dev/null +++ b/src/enrollments/components/EnrollmentStatusModal.tsx @@ -0,0 +1,52 @@ +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { useIntl } from '@openedx/frontend-base'; +import { Button, FormControl, ModalDialog } from '@openedx/paragon'; +import { useEnrollmentByUserId } from '../data/apiHook'; +import messages from '../messages'; + +interface EnrollmentStatusModalProps { + isOpen: boolean, + onClose: () => void, +} + +const EnrollmentStatusModal = ({ isOpen, onClose }: EnrollmentStatusModalProps) => { + const intl = useIntl(); + const { courseId = '' } = useParams<{ courseId: string }>(); + const [learnerIdentifier, setLearnerIdentifier] = useState(''); + const { data = { status: '' }, refetch } = useEnrollmentByUserId(courseId, learnerIdentifier); + + const handleSearch = async () => { + refetch(); + }; + + return ( + +

{intl.formatMessage(messages.checkEnrollmentStatus)}

+ +

{intl.formatMessage(messages.addLearnerInstructions)}

+ setLearnerIdentifier(e.target.value)} + /> + + + {data.status && learnerIdentifier && ( +

{intl.formatMessage(messages.statusResponseMessage, { learnerIdentifier, status: data.status })}

+ )} +
+ + + +
+ ); +}; + +export default EnrollmentStatusModal; diff --git a/src/enrollments/components/EnrollmentsList.tsx b/src/enrollments/components/EnrollmentsList.tsx new file mode 100644 index 0000000..c81cd99 --- /dev/null +++ b/src/enrollments/components/EnrollmentsList.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { ActionRow, Button, DataTable, IconButton } from '@openedx/paragon'; +import { useIntl } from '@openedx/frontend-base'; +import { MoreVert } from '@openedx/paragon/icons'; +import messages from '../messages'; +import { useEnrollments } from '../data/apiHook'; +import { Learner } from '../types'; + +const ENROLLMENTS_PAGE_SIZE = 25; + +const demoEnrollments = [ + { + id: '1', + username: 'johndoe', + fullName: 'John Doe', + email: 'johndoe@example.com', + track: 'Audit', + betaTester: true, + actions: , + }, +]; + +interface EnrollmentsListProps { + onUnenroll: (learner: Learner) => void, +} + +const EnrollmentsList = ({ onUnenroll }: EnrollmentsListProps) => { + const intl = useIntl(); + const { courseId } = useParams(); + const [page, setPage] = useState(0); + const { data = { count: 0, results: demoEnrollments }, isLoading } = useEnrollments(courseId ?? '', { + page, + pageSize: ENROLLMENTS_PAGE_SIZE + }); + + const pageCount = Math.ceil(data.count / ENROLLMENTS_PAGE_SIZE); + + const handleFetchData = (state: any) => { + setPage(state.pageIndex); + }; + + const handleMoreButton = () => { + // Handle more button click + console.log('More button clicked'); + }; + + const tableColumns = [ + { accessor: 'username', Header: intl.formatMessage(messages.username) }, + { accessor: 'fullName', Header: intl.formatMessage(messages.fullName) }, + { accessor: 'email', Header: intl.formatMessage(messages.email) }, + { accessor: 'track', Header: intl.formatMessage(messages.track) }, + { accessor: 'betaTester', Header: intl.formatMessage(messages.betaTester) }, + { accessor: 'actions', Header: intl.formatMessage(messages.actions) }, + ]; + + const tableData = data.results.map((learner: Learner) => ({ + id: learner.id, + username: learner.username, + fullName: learner.fullName, + email: learner.email, + track: learner.track ?? 'N/A', + betaTester: learner.betaTester ? 'True' : '', + actions: ( + + + + + ), + })); + + return ( + + ); +}; + +export default EnrollmentsList; diff --git a/src/enrollments/components/UnenrollModal.tsx b/src/enrollments/components/UnenrollModal.tsx new file mode 100644 index 0000000..cc0ee42 --- /dev/null +++ b/src/enrollments/components/UnenrollModal.tsx @@ -0,0 +1,36 @@ +import { useIntl } from '@openedx/frontend-base'; +import { Button, ModalDialog } from '@openedx/paragon'; +import messages from '../messages'; +import { Learner } from '../types'; + +interface UnenrollModalProps { + learner: Learner | null, + isOpen: boolean, + onClose: () => void, +} + +const UnenrollModal = ({ learner, isOpen, onClose }: UnenrollModalProps) => { + const intl = useIntl(); + + if (!isOpen || learner === null) { + onClose(); + return null; + } + + return ( + + +

{intl.formatMessage(messages.unenrollLearnerTitle)}

+
+ +

{intl.formatMessage(messages.unenrollLearnersConfirmation, { name: learner.fullName })}

+
+ + + + +
+ ); +}; + +export default UnenrollModal; diff --git a/src/enrollments/data/api.ts b/src/enrollments/data/api.ts new file mode 100644 index 0000000..d253b7b --- /dev/null +++ b/src/enrollments/data/api.ts @@ -0,0 +1,48 @@ +import { camelCaseObject, getAuthenticatedHttpClient } from '@openedx/frontend-base'; +import { getApiBaseUrl } from '../../data/api'; +import { EnrollmentsResponse, EnrollmentStatusResponse } from '../types'; + +export interface PaginationParams { + page: number, + pageSize: number, +} + +export const getEnrollments = async ( + courseId: string, + pagination: PaginationParams +): Promise => { + const { data } = await getAuthenticatedHttpClient().get( + `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/enrollments/?page=${pagination.page}&page_size=${pagination.pageSize}` + ); + return camelCaseObject(data); +}; + +export const getEnrollmentStatus = async ( + courseId: string, + userIdentifier: string +): Promise => { + const { data } = await getAuthenticatedHttpClient().get( + `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/enrollments/?email_or_username=${userIdentifier}` + ); + return camelCaseObject(data); +}; + +export const enrollLearners = async ( + courseId: string, + users: string[] +): Promise => { + await getAuthenticatedHttpClient().post( + `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/enrollments/`, + { users } + ); +}; + +export const unenrollLearners = async ( + courseId: string, + users: string[] +): Promise => { + await getAuthenticatedHttpClient().delete( + `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/enrollments/`, + { data: { users } } + ); +}; diff --git a/src/enrollments/data/apiHook.ts b/src/enrollments/data/apiHook.ts new file mode 100644 index 0000000..ae27b52 --- /dev/null +++ b/src/enrollments/data/apiHook.ts @@ -0,0 +1,28 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { enrollLearners, getEnrollments, getEnrollmentStatus, PaginationParams } from './api'; +import { enrollmentsQueryKeys } from './queryKeys'; + +export const useEnrollments = (courseId: string, pagination: PaginationParams) => ( + useQuery({ + queryKey: enrollmentsQueryKeys.byCoursePaginated(courseId, pagination), + queryFn: () => getEnrollments(courseId, pagination), + }) +); + +export const useEnrollmentByUserId = (courseId: string, userIdentifier: string) => ( + useQuery({ + queryKey: enrollmentsQueryKeys.byUserId(courseId, userIdentifier), + queryFn: () => getEnrollmentStatus(courseId, userIdentifier), + enabled: false, + }) +); + +export const useEnrollLearners = (courseId: string) => { + const queryClient = useQueryClient(); + return (useMutation({ + mutationFn: (users: string[]) => enrollLearners(courseId, users), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: enrollmentsQueryKeys.byCourse(courseId) }); + }, + })); +}; diff --git a/src/enrollments/data/queryKeys.ts b/src/enrollments/data/queryKeys.ts new file mode 100644 index 0000000..2568926 --- /dev/null +++ b/src/enrollments/data/queryKeys.ts @@ -0,0 +1,9 @@ +import { appId } from '../../constants'; +import { PaginationParams } from './api'; + +export const enrollmentsQueryKeys = { + all: [appId, 'enrollments'] as const, + byCourse: (courseId: string) => [...enrollmentsQueryKeys.all, courseId] as const, + byCoursePaginated: (courseId: string, pagination: PaginationParams) => [...enrollmentsQueryKeys.byCourse(courseId), pagination.page] as const, + byUserId: (courseId: string, userIdentifier: string) => [...enrollmentsQueryKeys.byCourse(courseId), 'enrollment', userIdentifier] as const, +}; diff --git a/src/enrollments/messages.ts b/src/enrollments/messages.ts new file mode 100644 index 0000000..2c77f60 --- /dev/null +++ b/src/enrollments/messages.ts @@ -0,0 +1,121 @@ +import { defineMessages } from '@openedx/frontend-base'; + +const messages = defineMessages({ + enrollmentsPageTitle: { + id: 'instruct.enrollments.page.title', + defaultMessage: 'Enrollment Management', + description: 'Title for the enrollments page', + }, + addBetaTesters: { + id: 'instruct.enrollments.addBetaTesters', + defaultMessage: 'Add Beta Testers', + description: 'Button label for adding beta testers', + }, + enrollLearners: { + id: 'instruct.enrollments.enrollLearners', + defaultMessage: 'Enroll Learners', + description: 'Button label an modal title for enrolling learners', + }, + checkEnrollmentStatus: { + id: 'instruct.enrollments.checkEnrollmentStatus', + defaultMessage: 'Check Enrollment Status', + description: 'Check enrollment status modal title and alt for icon button', + }, + username: { + id: 'instruct.enrollments.username', + defaultMessage: 'Username', + description: 'Column header for username in enrollments list', + }, + fullName: { + id: 'instruct.enrollments.fullName', + defaultMessage: 'Name', + description: 'Column header for full name in enrollments list', + }, + email: { + id: 'instruct.enrollments.email', + defaultMessage: 'Email', + description: 'Column header for email in enrollments list', + }, + track: { + id: 'instruct.enrollments.track', + defaultMessage: 'Track', + description: 'Column header for track in enrollments list', + }, + betaTester: { + id: 'instruct.enrollments.betaTester', + defaultMessage: 'Beta Tester', + description: 'Column header for beta tester status in enrollments list', + }, + actions: { + id: 'instruct.enrollments.actions', + defaultMessage: 'Actions', + description: 'Column header for actions in enrollments list', + }, + unenrollButton: { + id: 'instruct.enrollments.unenrollButton', + defaultMessage: 'Unenroll', + description: 'Button label for unenrolling a learner', + }, + trueLabel: { + id: 'instruct.enrollments.trueLabel', + defaultMessage: 'True', + description: 'Label for true boolean value', + }, + addLearnerInstructions: { + id: 'instruct.enrollments.modals.checkEnrollmentStatus.addLearnerInstructions', + defaultMessage: 'Learner’s My Open edX email address or username', + description: 'Instructions for enroll learners to the course', + }, + enrollmentStatusPlaceholder: { + id: 'instruct.enrollments.modals.checkEnrollmentStatus.enrollmentStatusPlaceholder', + defaultMessage: 'Learner email address or username', + description: 'Placeholder text for enrolling learners textarea', + }, + closeButton: { + id: 'instruct.enrollments.modals.closeButton', + defaultMessage: 'Close', + description: 'Label for close button in modals', + }, + statusResponseMessage: { + id: 'instruct.enrollments.modals.checkEnrollmentStatus.statusResponseMessage', + defaultMessage: 'Enrollment status for {learnerIdentifier}: {status}', + description: 'Message displaying the enrollment status for a learner', + }, + userIdentifierPlaceholder: { + id: 'instruct.enrollments.modals.enrollLearners.userIdentifierPlaceholder', + defaultMessage: 'Email addresses / Usernames', + description: 'Placeholder text for enrolling learners textarea', + }, + enrollLearnerInstructions: { + id: 'instruct.enrollments.modals.enrollLearners.enrollLearnerInstructions', + defaultMessage: 'Enter email addresses and/or usernames separated by new lines or commas. You will not get notification for emails that bounce, so please double-check spelling.', + description: 'Instructions for enrolling learners to the course', + }, + unenrollLearners: { + id: 'instruct.enrollments.modals.unenrollLearners', + defaultMessage: 'Unenroll Learners', + description: 'Title for unenroll learners modal', + }, + unenrollLearnersConfirmation: { + id: 'instruct.enrollments.modals.unenrollLearnersConfirmation', + defaultMessage: 'Unenroll {name} from course?', + description: 'Confirmation message for unenrolling learners', + }, + unenrollLearnerTitle: { + id: 'instruct.enrollments.modals.unenrollLearnerTitle', + defaultMessage: 'Unenroll Learner?', + description: 'Title for unenroll learner modal', + }, + saveButton: { + id: 'instruct.enrollments.modals.saveButton', + defaultMessage: 'Save', + description: 'Label for save button in modals', + }, + cancelButton: { + id: 'instruct.enrollments.modals.cancelButton', + defaultMessage: 'Cancel', + description: 'Label for cancel button in modals', + }, +}); + +export default messages; diff --git a/src/enrollments/types.ts b/src/enrollments/types.ts new file mode 100644 index 0000000..e073492 --- /dev/null +++ b/src/enrollments/types.ts @@ -0,0 +1,17 @@ +export interface EnrollmentsResponse { + count: number, + results: Learner[], +} + +export interface EnrollmentStatusResponse { + status: string, +} + +export interface Learner { + id: string, + username: string, + fullName: string, + email: string, + track: string, + betaTester: boolean, +}; diff --git a/src/routes.tsx b/src/routes.tsx index 96c1ac9..f1ab029 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -1,5 +1,6 @@ import CohortsPage from './cohorts/CohortsPage'; import CourseInfoPage from './courseInfo/CourseInfoPage'; +import EnrollmentsPage from './enrollments/EnrollmentsPage'; import Main from './Main'; const routes = [ @@ -15,10 +16,10 @@ const routes = [ path: 'course_info', element: }, - // { - // path: 'membership', - // element: - // }, + { + path: 'enrollments', + element: + }, { path: 'cohorts', element: