diff --git a/src/enrollments/EnrollmentsPage.test.tsx b/src/enrollments/EnrollmentsPage.test.tsx index c28d8477..c81bde6b 100644 --- a/src/enrollments/EnrollmentsPage.test.tsx +++ b/src/enrollments/EnrollmentsPage.test.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'; import EnrollmentsPage from './EnrollmentsPage'; import { EnrolledLearner } from '@src/enrollments/types'; import messages from '@src/enrollments/messages'; -import { useEnrollmentByUserId, useEnrollments, useUpdateEnrollments } from '@src/enrollments/data/apiHook'; +import { useEnrollmentByUserId, useEnrollments, useUpdateBetaTesters, useUpdateEnrollments } from '@src/enrollments/data/apiHook'; import { renderWithAlertAndIntl } from '@src/testUtils'; jest.mock('react-router-dom', () => ({ @@ -15,6 +15,7 @@ jest.mock('./data/apiHook', () => ({ useEnrollments: jest.fn(), useEnrollmentByUserId: jest.fn(), useUpdateEnrollments: jest.fn(), + useUpdateBetaTesters: jest.fn(), })); jest.mock('./components/EnrollmentsList', () => { @@ -52,6 +53,11 @@ describe('EnrollmentsPage', () => { isLoading: false, error: null, }); + (useUpdateBetaTesters as jest.Mock).mockReturnValue({ + mutate: jest.fn(), + isLoading: false, + error: null, + }); }); it('renders the page title', () => { diff --git a/src/enrollments/EnrollmentsPage.tsx b/src/enrollments/EnrollmentsPage.tsx index 8adc4853..a508e21e 100644 --- a/src/enrollments/EnrollmentsPage.tsx +++ b/src/enrollments/EnrollmentsPage.tsx @@ -3,18 +3,24 @@ import { useIntl } from '@openedx/frontend-base'; import { ActionRow, Button, IconButton } from '@openedx/paragon'; import { MoreVert } from '@openedx/paragon/icons'; import messages from '@src/enrollments/messages'; +import AddBetaTestersModal from '@src/enrollments/components/AddBetaTestersModal'; +import EnrollLearnersModal from '@src/enrollments/components/EnrollLearnersModal'; import EnrollmentsList from '@src/enrollments/components/EnrollmentsList'; import EnrollmentStatusModal from '@src/enrollments/components/EnrollmentStatusModal'; import UnenrollModal from '@src/enrollments/components/UnenrollModal'; -import EnrollLearnersModal from '@src/enrollments/components/EnrollLearnersModal'; import { EnrolledLearner } from '@src/enrollments/types'; +import { AlertOutlet, useAlert } from '@src/providers/AlertProvider'; +import UpdateBetaTesterModal from './components/UpdateBetaTesterModal'; const EnrollmentsPage = () => { const intl = useIntl(); const [isEnrollmentStatusModalOpen, setIsEnrollmentStatusModalOpen] = useState(false); const [isEnrollLearnersModalOpen, setIsEnrollLearnersModalOpen] = useState(false); + const [isAddBetaTestersModalOpen, setIsAddBetaTestersModalOpen] = useState(false); const [isUnenrollModalOpen, setIsUnenrollModalOpen] = useState(false); + const [isUpdateBetaTesterModalOpen, setIsUpdateBetaTesterModalOpen] = useState(false); const [selectedLearner, setSelectedLearner] = useState(null); + const { clearAlerts } = useAlert(); const handleMoreButton = () => { setIsEnrollmentStatusModalOpen(true); @@ -36,12 +42,28 @@ const EnrollmentsPage = () => { const handleEnrollLearners = () => { setIsEnrollLearnersModalOpen(true); + clearAlerts(); }; const handleCloseEnrollLearnersModal = () => { setIsEnrollLearnersModalOpen(false); }; + const handleAddBetaTesters = () => { + setIsAddBetaTestersModalOpen(true); + clearAlerts(); + }; + + const handleBetaTesterChange = (learner: EnrolledLearner) => { + setIsUpdateBetaTesterModalOpen(true); + setSelectedLearner(learner); + }; + + const handleCloseUpdateBetaTesterModal = () => { + setIsUpdateBetaTesterModalOpen(false); + setSelectedLearner(null); + }; + return ( <>
@@ -53,14 +75,17 @@ const EnrollmentsPage = () => { iconAs={MoreVert} onClick={handleMoreButton} /> - +
- + + - {selectedLearner && } - + {selectedLearner && } + + setIsAddBetaTestersModalOpen(false)} /> + {selectedLearner && } ); }; diff --git a/src/enrollments/components/AddBetaTestersModal.test.tsx b/src/enrollments/components/AddBetaTestersModal.test.tsx new file mode 100644 index 00000000..cf0b6278 --- /dev/null +++ b/src/enrollments/components/AddBetaTestersModal.test.tsx @@ -0,0 +1,397 @@ +import userEvent from '@testing-library/user-event'; +import { screen, waitFor } from '@testing-library/react'; +import { isAxiosError } from 'axios'; +import AddBetaTestersModal, { AddBetaTestersModalProps } from '@src/enrollments/components/AddBetaTestersModal'; +import { useUpdateBetaTesters } from '@src/enrollments/data/apiHook'; +import messages from '@src/enrollments/messages'; +import { renderWithAlertAndIntl } from '@src/testUtils'; + +const defaultProps: AddBetaTestersModalProps = { + isOpen: true, + onClose: jest.fn(), +}; + +const mockShowModal = jest.fn(); +const mockAddAlert = jest.fn(); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ courseId: 'test-course-id' }), +})); + +jest.mock('@src/enrollments/data/apiHook', () => ({ + useUpdateBetaTesters: jest.fn(), +})); + +jest.mock('@src/providers/AlertProvider', () => ({ + useAlert: () => ({ + showModal: mockShowModal, + addAlert: mockAddAlert, + }), + AlertProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +jest.mock('axios', () => ({ + isAxiosError: jest.fn(), +})); + +const renderComponent = (props = {}) => + renderWithAlertAndIntl(); + +describe('AddBetaTestersModal', () => { + const mutateMock = jest.fn(); + + beforeEach(() => { + (useUpdateBetaTesters as jest.Mock).mockReturnValue({ mutate: mutateMock }); + mockShowModal.mockClear(); + mockAddAlert.mockClear(); + (isAxiosError as unknown as jest.Mock).mockReturnValue(false); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders modal with title and instructions', () => { + renderComponent(); + expect(screen.getByRole('dialog', { name: messages.addBetaTesters.defaultMessage })).toBeInTheDocument(); + expect(screen.getByText(messages.addBetaTesters.defaultMessage)).toBeInTheDocument(); + expect(screen.getByText(messages.addBetaTestersInstructions.defaultMessage)).toBeInTheDocument(); + }); + + it('renders textarea with placeholder', () => { + renderComponent(); + expect( + screen.getByPlaceholderText(messages.userIdentifierPlaceholder.defaultMessage) + ).toBeInTheDocument(); + }); + + it('renders checkboxes with correct labels', () => { + renderComponent(); + expect( + screen.getByLabelText(messages.autoEnrollCheckbox.defaultMessage) + ).toBeInTheDocument(); + expect( + screen.getByLabelText(messages.notifyUsersCheckbox.defaultMessage) + ).toBeInTheDocument(); + }); + + it('calls onClose when Cancel button is clicked', async () => { + renderComponent(); + const cancelBtn = screen.getByRole('button', { + name: messages.cancelButton.defaultMessage, + }); + const user = userEvent.setup(); + await user.click(cancelBtn); + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it('Save button is disabled when textarea is empty', () => { + renderComponent(); + const saveBtn = screen.getByRole('button', { + name: messages.saveButton.defaultMessage, + }); + expect(saveBtn).toBeDisabled(); + }); + + it('Save button is enabled when textarea has input', async () => { + renderComponent(); + const textarea = screen.getByPlaceholderText( + messages.userIdentifierPlaceholder.defaultMessage + ); + const user = userEvent.setup(); + await user.type(textarea, 'test@example.com'); + const saveBtn = screen.getByRole('button', { + name: messages.saveButton.defaultMessage, + }); + expect(saveBtn).toBeEnabled(); + }); + + it('calls addBetaTesters with trimmed email list when Save is clicked', async () => { + renderComponent(); + const textarea = screen.getByPlaceholderText( + messages.userIdentifierPlaceholder.defaultMessage + ); + const user = userEvent.setup(); + await user.type(textarea, ' alice@example.com, bob@example.com '); + const saveBtn = screen.getByRole('button', { + name: messages.saveButton.defaultMessage, + }); + await user.click(saveBtn); + expect(mutateMock).toHaveBeenCalledWith({ + identifier: [ + 'alice@example.com', + 'bob@example.com', + ], + action: 'add', + autoEnroll: true, + emailStudents: true, + }, { + onSuccess: expect.any(Function), + onError: expect.any(Function), + }); + }); + + it('splits emails by comma and trims whitespace', async () => { + renderComponent(); + const textarea = screen.getByPlaceholderText( + messages.userIdentifierPlaceholder.defaultMessage + ); + const user = userEvent.setup(); + await user.type(textarea, 'a@a.com, b@b.com ,c@c.com'); + const saveBtn = screen.getByRole('button', { + name: messages.saveButton.defaultMessage, + }); + await user.click(saveBtn); + expect(mutateMock).toHaveBeenCalledWith({ + identifier: [ + 'a@a.com', + 'b@b.com', + 'c@c.com', + ], + action: 'add', + autoEnroll: true, + emailStudents: true, + }, { + onSuccess: expect.any(Function), + onError: expect.any(Function), + }); + }); + + it('does not call mutation if textarea is empty', async () => { + renderComponent(); + const saveBtn = screen.getByRole('button', { + name: messages.saveButton.defaultMessage, + }); + const user = userEvent.setup(); + expect(saveBtn).toBeDisabled(); + await user.click(saveBtn); + expect(mutateMock).not.toHaveBeenCalled(); + }); + + it('does not render modal when isOpen is false', () => { + renderComponent({ isOpen: false }); + expect(screen.queryByText(messages.addBetaTesters.defaultMessage)).not.toBeInTheDocument(); + }); + + it('calls onClose when mutation succeeds with no failures', async () => { + const mutateWithCallback = (_users: any, callbacks: any) => { + callbacks.onSuccess({ results: [{ identifier: 'test@example.com', userDoesNotExist: false }] }); + }; + mutateMock.mockImplementation(mutateWithCallback); + + renderComponent(); + const textarea = screen.getByPlaceholderText( + messages.userIdentifierPlaceholder.defaultMessage + ); + const user = userEvent.setup(); + await user.type(textarea, 'test@example.com'); + const saveBtn = screen.getByRole('button', { + name: messages.saveButton.defaultMessage, + }); + await user.click(saveBtn); + + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it('shows alert for failed users and still calls onClose', async () => { + const mutateWithCallback = (_users: any, callbacks: any) => { + callbacks.onSuccess({ + results: [ + { identifier: 'valid@example.com', userDoesNotExist: false }, + { identifier: 'invalid@example.com', userDoesNotExist: true } + ] + }); + }; + mutateMock.mockImplementation(mutateWithCallback); + + renderComponent(); + const textarea = screen.getByPlaceholderText( + messages.userIdentifierPlaceholder.defaultMessage + ); + const user = userEvent.setup(); + await user.type(textarea, 'valid@example.com, invalid@example.com'); + const saveBtn = screen.getByRole('button', { + name: messages.saveButton.defaultMessage, + }); + await user.click(saveBtn); + + expect(mockAddAlert).toHaveBeenCalledWith({ + type: 'danger', + message: messages.failedBetaTesters.defaultMessage, + extraContent: expect.any(Array), + }); + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it('shows error modal when mutation fails with 404 error', async () => { + (isAxiosError as unknown as jest.Mock).mockReturnValue(true); + const mutateWithError = (_users: any, callbacks: any) => { + callbacks.onError({ response: { status: 404 } }); + }; + mutateMock.mockImplementation(mutateWithError); + + renderComponent(); + const textarea = screen.getByPlaceholderText( + messages.userIdentifierPlaceholder.defaultMessage + ); + const user = userEvent.setup(); + await user.type(textarea, 'test@example.com'); + const saveBtn = screen.getByRole('button', { + name: messages.saveButton.defaultMessage, + }); + await user.click(saveBtn); + + expect(mockShowModal).toHaveBeenCalledWith({ + message: messages.enrollLearnerNotFoundError.defaultMessage, + variant: 'danger', + confirmText: messages.closeButton.defaultMessage, + }); + }); + + it('shows general error modal when mutation fails with non-404 error', async () => { + (isAxiosError as unknown as jest.Mock).mockReturnValue(true); + const mutateWithError = (_users: any, callbacks: any) => { + callbacks.onError({ response: { status: 500 } }); + }; + mutateMock.mockImplementation(mutateWithError); + + renderComponent(); + const textarea = screen.getByPlaceholderText( + messages.userIdentifierPlaceholder.defaultMessage + ); + const user = userEvent.setup(); + await user.type(textarea, 'test@example.com'); + const saveBtn = screen.getByRole('button', { + name: messages.saveButton.defaultMessage, + }); + await user.click(saveBtn); + + expect(mockShowModal).toHaveBeenCalledWith({ + message: messages.enrollLearnerError.defaultMessage, + variant: 'danger', + confirmText: messages.closeButton.defaultMessage, + }); + }); + + it('shows general error modal when mutation fails with non-axios error', async () => { + (isAxiosError as unknown as jest.Mock).mockReturnValue(false); + const mutateWithError = (_users: any, callbacks: any) => { + callbacks.onError({}); + }; + mutateMock.mockImplementation(mutateWithError); + + renderComponent(); + const textarea = screen.getByPlaceholderText( + messages.userIdentifierPlaceholder.defaultMessage + ); + const user = userEvent.setup(); + await user.type(textarea, 'test@example.com'); + const saveBtn = screen.getByRole('button', { + name: messages.saveButton.defaultMessage, + }); + await user.click(saveBtn); + + expect(mockShowModal).toHaveBeenCalledWith({ + message: messages.enrollLearnerError.defaultMessage, + variant: 'danger', + confirmText: messages.closeButton.defaultMessage, + }); + }); + + it('filters out empty emails from the list', async () => { + renderComponent(); + const textarea = screen.getByPlaceholderText( + messages.userIdentifierPlaceholder.defaultMessage + ); + const user = userEvent.setup(); + await user.type(textarea, 'alice@example.com,,, ,bob@example.com'); + const saveBtn = screen.getByRole('button', { + name: messages.saveButton.defaultMessage, + }); + await user.click(saveBtn); + + expect(mutateMock).toHaveBeenCalledWith({ + identifier: [ + 'alice@example.com', + 'bob@example.com', + ], + action: 'add', + autoEnroll: true, + emailStudents: true, + }, { + onSuccess: expect.any(Function), + onError: expect.any(Function), + }); + }); + + it('updates checkbox states correctly', async () => { + renderComponent(); + const autoEnrollCheckbox = screen.getByLabelText(messages.autoEnrollCheckbox.defaultMessage); + const notifyUsersCheckbox = screen.getByLabelText(messages.notifyUsersCheckbox.defaultMessage); + const user = userEvent.setup(); + + // Initially both should be checked + expect(autoEnrollCheckbox).toBeChecked(); + expect(notifyUsersCheckbox).toBeChecked(); + + // Uncheck auto enroll + await user.click(autoEnrollCheckbox); + expect(autoEnrollCheckbox).not.toBeChecked(); + + // Uncheck notify users + await user.click(notifyUsersCheckbox); + expect(notifyUsersCheckbox).not.toBeChecked(); + + // Test that the values are passed correctly to mutation + const textarea = screen.getByPlaceholderText( + messages.userIdentifierPlaceholder.defaultMessage + ); + await user.type(textarea, 'test@example.com'); + const saveBtn = screen.getByRole('button', { + name: messages.saveButton.defaultMessage, + }); + await user.click(saveBtn); + + expect(mutateMock).toHaveBeenCalledWith({ + identifier: ['test@example.com'], + action: 'add', + autoEnroll: false, + emailStudents: false, + }, { + onSuccess: expect.any(Function), + onError: expect.any(Function), + }); + }); + + it('resets form state after successful submission', async () => { + const mutateWithCallback = (_users: any, callbacks: any) => { + callbacks.onSuccess({ results: [] }); + }; + mutateMock.mockImplementation(mutateWithCallback); + + renderComponent(); + const textarea = screen.getByPlaceholderText( + messages.userIdentifierPlaceholder.defaultMessage + ) as HTMLTextAreaElement; + const autoEnrollCheckbox = screen.getByLabelText(messages.autoEnrollCheckbox.defaultMessage); + const notifyUsersCheckbox = screen.getByLabelText(messages.notifyUsersCheckbox.defaultMessage); + const user = userEvent.setup(); + + // Set some values + await user.type(textarea, 'test@example.com'); + await user.click(autoEnrollCheckbox); // Uncheck + await user.click(notifyUsersCheckbox); // Uncheck + + const saveBtn = screen.getByRole('button', { + name: messages.saveButton.defaultMessage, + }); + await user.click(saveBtn); + + // After successful submission, form should be cleaned and closed + await waitFor(() => expect(textarea.value).toBe('')); + expect(autoEnrollCheckbox).toBeChecked(); + expect(notifyUsersCheckbox).toBeChecked(); + expect(defaultProps.onClose).toHaveBeenCalled(); + }); +}); diff --git a/src/enrollments/components/AddBetaTestersModal.tsx b/src/enrollments/components/AddBetaTestersModal.tsx new file mode 100644 index 00000000..703bf607 --- /dev/null +++ b/src/enrollments/components/AddBetaTestersModal.tsx @@ -0,0 +1,116 @@ +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { isAxiosError } from 'axios'; +import { useIntl } from '@openedx/frontend-base'; +import { Button, FormControl, ModalDialog, Form } from '@openedx/paragon'; +import { useUpdateBetaTesters } from '@src/enrollments/data/apiHook'; +import messages from '@src/enrollments/messages'; +import { useAlert } from '@src/providers/AlertProvider'; +import { useDebouncedFilter } from '@src/hooks/useDebouncedFilter'; + +export interface AddBetaTestersModalProps { + isOpen: boolean, + onClose: () => void, +} + +const AddBetaTestersModal = ({ + isOpen, + onClose +}: AddBetaTestersModalProps) => { + const intl = useIntl(); + const { courseId = '' } = useParams<{ courseId: string }>(); + const [emails, setEmails] = useState(''); + const [autoEnroll, setAutoEnroll] = useState(true); + const [emailStudents, setEmailStudents] = useState(true); + const { mutate: addBetaTesters } = useUpdateBetaTesters(courseId); + const { showModal, addAlert } = useAlert(); + const { inputValue, handleChange } = useDebouncedFilter({ + filterValue: emails, + setFilter: setEmails, + }); + + const handleInputChange = (e: React.ChangeEvent) => { + handleChange(e.target.value); + }; + + const handleSave = () => { + const identifier = inputValue.split(',').map(email => email.trim()).filter(email => email); + addBetaTesters({ identifier, action: 'add', autoEnroll, emailStudents }, { + onSuccess: (data) => { + const failedUsernames = data.results?.filter(user => user.userDoesNotExist).map(user => user.identifier) || []; + if (failedUsernames.length > 0) { + addAlert({ + type: 'danger', + message: intl.formatMessage(messages.failedBetaTesters), + extraContent: ( + failedUsernames.map((learner: string) => ( +

• {intl.formatMessage(messages.unknownLearner, { learner })}

+ )) + ) + }); + } + handleChange(''); + setAutoEnroll(true); + setEmailStudents(true); + onClose(); + }, + onError: (error) => { + const notFound = isAxiosError(error) && error.response?.status === 404; + const errorMessage = notFound + ? intl.formatMessage(messages.enrollLearnerNotFoundError) + : intl.formatMessage(messages.enrollLearnerError); + showModal({ + message: errorMessage, + variant: 'danger', + confirmText: intl.formatMessage(messages.closeButton), + }); + } + }); + }; + + return ( + + +

{intl.formatMessage(messages.addBetaTesters)}

+
+
+ +

{intl.formatMessage(messages.addBetaTestersInstructions)}

+ +
+ ) => setAutoEnroll(e.target.checked)} + >{intl.formatMessage(messages.autoEnrollCheckbox)} + + ) => setEmailStudents(e.target.checked)} + >{intl.formatMessage(messages.notifyUsersCheckbox)} + +
+
+
+ + + + +
+ ); +}; + +export default AddBetaTestersModal; diff --git a/src/enrollments/components/EnrollLearnersModal.test.tsx b/src/enrollments/components/EnrollLearnersModal.test.tsx index 24d20e75..08766437 100644 --- a/src/enrollments/components/EnrollLearnersModal.test.tsx +++ b/src/enrollments/components/EnrollLearnersModal.test.tsx @@ -8,7 +8,6 @@ import { renderWithAlertAndIntl } from '@src/testUtils'; const defaultProps: EnrollLearnersModalProps = { isOpen: true, onClose: jest.fn(), - onSuccess: jest.fn(), }; const mockShowModal = jest.fn(); @@ -157,7 +156,7 @@ describe('EnrollLearnersModal', () => { const user = userEvent.setup(); expect(saveBtn).toBeDisabled(); await user.click(saveBtn); - expect(defaultProps.onSuccess).not.toHaveBeenCalled(); + expect(defaultProps.onClose).not.toHaveBeenCalled(); }); it('does not render modal when isOpen is false', () => { @@ -165,10 +164,9 @@ describe('EnrollLearnersModal', () => { expect(screen.queryByText(messages.enrollLearners.defaultMessage)).not.toBeInTheDocument(); }); - it('calls onSuccess and onClose when mutation succeeds', async () => { + it('calls onClose when mutation succeeds', async () => { const mutateWithCallback = (_users: string[], callbacks: any) => { - // Call onSuccess for the success scenario - callbacks.onSuccess(); + callbacks.onSuccess({ results: [{ identifier: 'test@example.com', invalidIdentifier: false }] }); }; mutateMock.mockImplementation(mutateWithCallback); @@ -183,13 +181,12 @@ describe('EnrollLearnersModal', () => { }); await user.click(saveBtn); - expect(defaultProps.onSuccess).toHaveBeenCalled(); + expect(defaultProps.onClose).toHaveBeenCalled(); }); it('shows error alert when mutation fails', async () => { - const errorMessage = 'Enrollment failed'; const mutateWithError = (_users: string[], callbacks: any) => { - callbacks.onError({ message: errorMessage }); + callbacks.onError({ message: messages.enrollLearnerError.defaultMessage }); }; mutateMock.mockImplementation(mutateWithError); @@ -205,7 +202,7 @@ describe('EnrollLearnersModal', () => { await user.click(saveBtn); expect(mockShowModal).toHaveBeenCalledWith({ - message: errorMessage, + message: messages.enrollLearnerError.defaultMessage, variant: 'danger', confirmText: messages.closeButton.defaultMessage, }); diff --git a/src/enrollments/components/EnrollLearnersModal.tsx b/src/enrollments/components/EnrollLearnersModal.tsx index 18a7cda3..46a67f74 100644 --- a/src/enrollments/components/EnrollLearnersModal.tsx +++ b/src/enrollments/components/EnrollLearnersModal.tsx @@ -10,13 +10,11 @@ import { useAlert } from '@src/providers/AlertProvider'; export interface EnrollLearnersModalProps { isOpen: boolean, onClose: () => void, - onSuccess: () => void, } const EnrollLearnersModal = ({ isOpen, - onClose, - onSuccess + onClose }: EnrollLearnersModalProps) => { const intl = useIntl(); const { courseId = '' } = useParams<{ courseId: string }>(); @@ -24,22 +22,34 @@ const EnrollLearnersModal = ({ const [autoEnroll, setAutoEnroll] = useState(true); const [emailStudents, setEmailStudents] = useState(true); const { mutate: enrollLearners } = useUpdateEnrollments(courseId); - const { showModal } = useAlert(); + const { showModal, addAlert } = useAlert(); const handleSave = () => { const identifier = emails.split(',').map(email => email.trim()).filter(email => email); enrollLearners({ identifier, action: 'enroll', autoEnroll, emailStudents }, { - onSuccess: () => { + onSuccess: (data) => { + const failedUsernames = data.results?.filter(user => user.invalidIdentifier).map(user => user.identifier) || []; + if (failedUsernames.length > 0) { + addAlert({ + type: 'danger', + message: intl.formatMessage(messages.failedEnrollLearners), + extraContent: ( + failedUsernames.map((learner: string) => ( +

• {intl.formatMessage(messages.unknownLearner, { learner })}

+ )) + ) + }); + } setEmails(''); setAutoEnroll(true); setEmailStudents(true); - onSuccess(); + onClose(); }, onError: (error) => { const notFound = isAxiosError(error) && error.response?.status === 404; const errorMessage = notFound ? intl.formatMessage(messages.enrollLearnerNotFoundError) - : error.message || intl.formatMessage(messages.enrollLearnerError); + : intl.formatMessage(messages.enrollLearnerError); showModal({ message: errorMessage, variant: 'danger', diff --git a/src/enrollments/components/EnrollmentsList.test.tsx b/src/enrollments/components/EnrollmentsList.test.tsx index 1dbe7d0f..680d6394 100644 --- a/src/enrollments/components/EnrollmentsList.test.tsx +++ b/src/enrollments/components/EnrollmentsList.test.tsx @@ -31,9 +31,9 @@ const mockLearners = [ }, ]; -const renderComponent = (onUnenroll = jest.fn()) => { +const renderComponent = (onUnenroll = jest.fn(), onBetaTesterChange = jest.fn()) => { return renderWithIntl( - + ); }; diff --git a/src/enrollments/components/EnrollmentsList.tsx b/src/enrollments/components/EnrollmentsList.tsx index 3bf4237d..28408edb 100644 --- a/src/enrollments/components/EnrollmentsList.tsx +++ b/src/enrollments/components/EnrollmentsList.tsx @@ -1,7 +1,7 @@ import { useCallback, useState } from 'react'; import { useParams } from 'react-router-dom'; import { useIntl } from '@openedx/frontend-base'; -import { ActionRow, Button, DataTable, FormControl, Icon, IconButton } from '@openedx/paragon'; +import { ActionRow, Button, DataTable, FormControl, Icon, IconButton, OverlayTrigger, Popover } from '@openedx/paragon'; import { FilterList, MoreVert, Search } from '@openedx/paragon/icons'; import messages from '@src/enrollments/messages'; import { useEnrollments } from '@src/enrollments/data/apiHook'; @@ -19,6 +19,7 @@ const betaTesterOptions = [ interface EnrollmentsListProps { onUnenroll: (learner: EnrolledLearner) => void, + onBetaTesterChange: (learner: EnrolledLearner) => void, } const UsernameFilter = ({ column: { filterValue, setFilter } }: { column: { filterValue: string, setFilter: (value: string) => void } }) => { @@ -71,7 +72,7 @@ const BetaTesterFilter = ({ column: { filterValue, setFilter } }: { column: { fi ); }; -const EnrollmentsList = ({ onUnenroll }: EnrollmentsListProps) => { +const EnrollmentsList = ({ onUnenroll, onBetaTesterChange }: EnrollmentsListProps) => { const intl = useIntl(); const { courseId = '' } = useParams(); const [filters, setFilters] = useState({ page: 0, username: '', isBetaTester: '' }); @@ -104,11 +105,6 @@ const EnrollmentsList = ({ onUnenroll }: EnrollmentsListProps) => { } }; - const handleMoreButton = () => { - // Handle more button click - console.log('More button clicked'); - }; - const tableColumns = [ { accessor: 'username', Header: intl.formatMessage(messages.username), Filter: UsernameFilter }, { accessor: 'fullName', Header: intl.formatMessage(messages.fullName), disableFilters: true }, @@ -129,21 +125,48 @@ const EnrollmentsList = ({ onUnenroll }: EnrollmentsListProps) => { }, ]; - const actionCustomCell = useCallback(({ row: { original } }: TableCellValue) => { + const ActionCustomCell = useCallback(({ row: { original } }: TableCellValue) => { + const popoverContent = ( + + +
+ +
+
+
+ ); + return ( - + + + ); - }, [onUnenroll, intl]); + }, [intl, onBetaTesterChange, onUnenroll]); return ( { { id: 'actions', Header: intl.formatMessage(messages.actions), - Cell: actionCustomCell, + Cell: ActionCustomCell, } ]} data={data.results} diff --git a/src/enrollments/components/UnenrollModal.test.tsx b/src/enrollments/components/UnenrollModal.test.tsx index a1975c0a..eaf808d4 100644 --- a/src/enrollments/components/UnenrollModal.test.tsx +++ b/src/enrollments/components/UnenrollModal.test.tsx @@ -18,7 +18,6 @@ const defaultProps = { learner, isOpen: true, onClose: jest.fn(), - onSuccess: jest.fn(), }; const mockShowModal = jest.fn(); @@ -104,7 +103,6 @@ describe('UnenrollModal', () => { ); await userEvent.click(screen.getByRole('button', { name: /^unenroll$/i })); - expect(defaultProps.onSuccess).toHaveBeenCalled(); expect(defaultProps.onClose).toHaveBeenCalled(); }); diff --git a/src/enrollments/components/UnenrollModal.tsx b/src/enrollments/components/UnenrollModal.tsx index 5113e2ac..685096ec 100644 --- a/src/enrollments/components/UnenrollModal.tsx +++ b/src/enrollments/components/UnenrollModal.tsx @@ -10,10 +10,9 @@ interface UnenrollModalProps { learner: EnrolledLearner, isOpen: boolean, onClose: () => void, - onSuccess: () => void, } -const UnenrollModal = ({ learner, isOpen, onClose, onSuccess }: UnenrollModalProps) => { +const UnenrollModal = ({ learner, isOpen, onClose }: UnenrollModalProps) => { const intl = useIntl(); const { courseId = '' } = useParams<{ courseId: string }>(); const { mutate: unenrollLearners, isPending } = useUpdateEnrollments(courseId); @@ -25,7 +24,6 @@ const UnenrollModal = ({ learner, isOpen, onClose, onSuccess }: UnenrollModalPro action: 'unenroll', }, { onSuccess: () => { - onSuccess(); onClose(); }, onError: (error) => { diff --git a/src/enrollments/components/UpdateBetaTesterModal.test.tsx b/src/enrollments/components/UpdateBetaTesterModal.test.tsx new file mode 100644 index 00000000..8a54eaac --- /dev/null +++ b/src/enrollments/components/UpdateBetaTesterModal.test.tsx @@ -0,0 +1,372 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import UpdateBetaTesterModal from '@src/enrollments/components/UpdateBetaTesterModal'; +import { useUpdateBetaTesters } from '@src/enrollments/data/apiHook'; +import messages from '@src/enrollments/messages'; +import { UpdateBetaTestersParams } from '@src/enrollments/types'; +import { renderWithAlertAndIntl } from '@src/testUtils'; + +const learnerBetaTester = { + fullName: 'Jane Doe', + email: 'jane@example.com', + isBetaTester: true, + username: 'jane.doe', + mode: 'verified', +}; + +const learnerNonBetaTester = { + fullName: 'John Smith', + email: 'john@example.com', + isBetaTester: false, + username: 'john.smith', + mode: 'verified', +}; + +const defaultProps = { + learner: learnerBetaTester, + isOpen: true, + onClose: jest.fn(), +}; + +const mockShowModal = jest.fn(); +const mockAddAlert = jest.fn(); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ courseId: 'test-course-id' }), +})); + +jest.mock('@src/enrollments/data/apiHook', () => ({ + useUpdateBetaTesters: jest.fn(), +})); + +jest.mock('@src/providers/AlertProvider', () => ({ + useAlert: () => ({ + showModal: mockShowModal, + addAlert: mockAddAlert, + }), + AlertProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +describe('UpdateBetaTesterModal', () => { + const mutateMock = jest.fn(); + + beforeEach(() => { + (useUpdateBetaTesters as jest.Mock).mockReturnValue({ + mutate: mutateMock, + isPending: false + }); + mockShowModal.mockClear(); + mockAddAlert.mockClear(); + defaultProps.onClose.mockClear(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when learner is a beta tester (isBetaTester: true)', () => { + it('renders modal with correct title and confirmation message', () => { + renderWithAlertAndIntl( + + ); + expect(screen.getByRole('dialog', { name: /revoke access/i })).toBeInTheDocument(); + expect(screen.getByText(messages.removeBetaTesterDescription.defaultMessage)).toBeInTheDocument(); + }); + + it('renders Cancel and Revoke buttons', () => { + renderWithAlertAndIntl( + + ); + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /revoke/i })).toBeInTheDocument(); + }); + + it('calls onClose when Cancel button is clicked', async () => { + const onClose = jest.fn(); + renderWithAlertAndIntl( + + ); + await userEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect(onClose).toHaveBeenCalled(); + }); + + it('calls updateBetaTester with remove action when Revoke button is clicked', async () => { + renderWithAlertAndIntl( + + ); + await userEvent.click(screen.getByRole('button', { name: /revoke/i })); + expect(mutateMock).toHaveBeenCalledWith({ + identifier: [learnerBetaTester.username], + action: 'remove', + }, { + onSuccess: expect.any(Function), + onError: expect.any(Function), + }); + }); + + it('calls onClose when Revoke button is clicked', async () => { + renderWithAlertAndIntl( + + ); + await userEvent.click(screen.getByRole('button', { name: /revoke/i })); + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it('disables revoke button when pending', () => { + (useUpdateBetaTesters as jest.Mock).mockReturnValue({ + mutate: mutateMock, + isPending: true + }); + + renderWithAlertAndIntl( + + ); + + const revokeButton = screen.getByRole('button', { name: messages.revoke.defaultMessage }); + expect(revokeButton).toBeDisabled(); + }); + + it('calls onClose when modal close button is clicked', async () => { + const onClose = jest.fn(); + renderWithAlertAndIntl( + + ); + const closeButton = screen.getByLabelText(/close/i); + await userEvent.click(closeButton); + expect(onClose).toHaveBeenCalled(); + }); + + it('does not render modal when isOpen is false', () => { + renderWithAlertAndIntl( + + ); + expect(screen.queryByText(messages.removeBetaTesterDescription.defaultMessage)).not.toBeInTheDocument(); + }); + }); + + describe('when learner is not a beta tester (isBetaTester: false)', () => { + const nonBetaTesterProps = { + ...defaultProps, + learner: learnerNonBetaTester, + }; + + it('automatically calls updateBetaTester with add action and does not render modal', () => { + renderWithAlertAndIntl( + + ); + + expect(mutateMock).toHaveBeenCalledWith({ + identifier: [learnerNonBetaTester.username], + action: 'add', + }, { + onSuccess: expect.any(Function), + onError: expect.any(Function), + }); + + // Modal should not be rendered + expect(screen.queryByText(messages.removeBetaTesterDescription.defaultMessage)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /revoke/i })).not.toBeInTheDocument(); + }); + + it('calls onClose when automatically adding beta tester', () => { + renderWithAlertAndIntl( + + ); + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + }); + + describe('mutation success scenarios', () => { + it('shows alert with failed usernames when some users fail (remove action)', async () => { + const mockSuccessData = { + results: [ + { identifier: 'failed-user', userDoesNotExist: true }, + { identifier: 'jane.doe', userDoesNotExist: false }, + ] + }; + + const mutateWithSuccess = (_params: UpdateBetaTestersParams, callbacks: any) => { + callbacks.onSuccess(mockSuccessData); + }; + mutateMock.mockImplementation(mutateWithSuccess); + + renderWithAlertAndIntl( + + ); + await userEvent.click(screen.getByRole('button', { name: /revoke/i })); + + expect(mockAddAlert).toHaveBeenCalledWith({ + type: 'danger', + message: messages.failedBetaTesters.defaultMessage, + extraContent: expect.any(Array), + }); + }); + + it('shows alert with failed usernames when some users fail (add action)', async () => { + const mockSuccessData = { + results: [ + { identifier: 'failed-user', userDoesNotExist: true }, + ] + }; + + const mutateWithSuccess = (_params: UpdateBetaTestersParams, callbacks: any) => { + callbacks.onSuccess(mockSuccessData); + }; + mutateMock.mockImplementation(mutateWithSuccess); + + const nonBetaTesterProps = { + ...defaultProps, + learner: learnerNonBetaTester, + }; + + renderWithAlertAndIntl( + + ); + + expect(mockAddAlert).toHaveBeenCalledWith({ + type: 'danger', + message: messages.failedBetaTesters.defaultMessage, + extraContent: expect.any(Array), + }); + }); + + it('does not show alert when all users succeed', async () => { + const mockSuccessData = { + results: [ + { identifier: 'jane.doe', userDoesNotExist: false }, + ] + }; + + const mutateWithSuccess = (_params: UpdateBetaTestersParams, callbacks: any) => { + callbacks.onSuccess(mockSuccessData); + }; + mutateMock.mockImplementation(mutateWithSuccess); + + renderWithAlertAndIntl( + + ); + await userEvent.click(screen.getByRole('button', { name: /revoke/i })); + + expect(mockAddAlert).not.toHaveBeenCalled(); + }); + + it('does not show alert when results array is empty', async () => { + const mockSuccessData = { + results: [] + }; + + const mutateWithSuccess = (_params: UpdateBetaTestersParams, callbacks: any) => { + callbacks.onSuccess(mockSuccessData); + }; + mutateMock.mockImplementation(mutateWithSuccess); + + renderWithAlertAndIntl( + + ); + await userEvent.click(screen.getByRole('button', { name: /revoke/i })); + + expect(mockAddAlert).not.toHaveBeenCalled(); + }); + }); + + describe('mutation error scenarios', () => { + it('shows error modal when removing beta tester fails', async () => { + const mutateWithError = (_params: UpdateBetaTestersParams, callbacks: any) => { + callbacks.onError(); + }; + mutateMock.mockImplementation(mutateWithError); + + renderWithAlertAndIntl( + + ); + await userEvent.click(screen.getByRole('button', { name: /revoke/i })); + + expect(mockShowModal).toHaveBeenCalledWith({ + message: messages.removeBetaTesterError.defaultMessage, + variant: 'danger', + confirmText: messages.closeButton.defaultMessage, + }); + }); + + it('shows error modal when adding beta tester fails', async () => { + const mutateWithError = (_params: UpdateBetaTestersParams, callbacks: any) => { + callbacks.onError(); + }; + mutateMock.mockImplementation(mutateWithError); + + const nonBetaTesterProps = { + ...defaultProps, + learner: learnerNonBetaTester, + }; + + renderWithAlertAndIntl( + + ); + + expect(mockShowModal).toHaveBeenCalledWith({ + message: messages.addBetaTesterError.defaultMessage, + variant: 'danger', + confirmText: messages.closeButton.defaultMessage, + }); + }); + }); + + describe('edge cases', () => { + it('handles success callback with undefined results', async () => { + const mockSuccessData = {}; + + const mutateWithSuccess = (_params: UpdateBetaTestersParams, callbacks: any) => { + callbacks.onSuccess(mockSuccessData); + }; + mutateMock.mockImplementation(mutateWithSuccess); + + renderWithAlertAndIntl( + + ); + await userEvent.click(screen.getByRole('button', { name: /revoke/i })); + + expect(mockAddAlert).not.toHaveBeenCalled(); + }); + + it('handles success callback with null results', async () => { + const mockSuccessData = { + results: null + }; + + const mutateWithSuccess = (_params: UpdateBetaTestersParams, callbacks: any) => { + callbacks.onSuccess(mockSuccessData); + }; + mutateMock.mockImplementation(mutateWithSuccess); + + renderWithAlertAndIntl( + + ); + await userEvent.click(screen.getByRole('button', { name: /revoke/i })); + + expect(mockAddAlert).not.toHaveBeenCalled(); + }); + + it('renders failed learner names correctly in alert', async () => { + const mockSuccessData = { + results: [ + { identifier: 'user1@example.com', userDoesNotExist: true }, + { identifier: 'user2', userDoesNotExist: true }, + ] + }; + + const mutateWithSuccess = (_params: UpdateBetaTestersParams, callbacks: any) => { + callbacks.onSuccess(mockSuccessData); + }; + mutateMock.mockImplementation(mutateWithSuccess); + + renderWithAlertAndIntl( + + ); + await userEvent.click(screen.getByRole('button', { name: /revoke/i })); + + const alertCall = mockAddAlert.mock.calls[0][0]; + expect(alertCall.extraContent).toHaveLength(2); + }); + }); +}); diff --git a/src/enrollments/components/UpdateBetaTesterModal.tsx b/src/enrollments/components/UpdateBetaTesterModal.tsx new file mode 100644 index 00000000..cfef0eeb --- /dev/null +++ b/src/enrollments/components/UpdateBetaTesterModal.tsx @@ -0,0 +1,86 @@ +import { useParams } from 'react-router-dom'; +import { useCallback, useEffect } from 'react'; +import { useIntl } from '@openedx/frontend-base'; +import { Button, ModalDialog } from '@openedx/paragon'; +import { useAlert } from '@src/providers/AlertProvider'; +import { useUpdateBetaTesters } from '@src/enrollments/data/apiHook'; +import messages from '@src/enrollments/messages'; +import { EnrolledLearner } from '@src/enrollments/types'; + +interface UpdateBetaTesterModalProps { + learner: EnrolledLearner, + isOpen: boolean, + onClose: () => void, +} + +const UpdateBetaTesterModal = ({ learner, isOpen, onClose }: UpdateBetaTesterModalProps) => { + const intl = useIntl(); + const { courseId = '' } = useParams<{ courseId: string }>(); + const { mutate: updateBetaTester, isPending } = useUpdateBetaTesters(courseId); + const { addAlert, showModal } = useAlert(); + + const handleUpdateBetaTester = useCallback(() => { + updateBetaTester({ + identifier: [learner.username], + action: learner.isBetaTester ? 'remove' : 'add', + }, { + onSuccess: (data) => { + const failedUsernames = data.results?.filter(user => user.userDoesNotExist).map(user => user.identifier) || []; + if (failedUsernames.length > 0) { + addAlert({ + type: 'danger', + message: intl.formatMessage(messages.failedBetaTesters), + extraContent: ( + failedUsernames.map((learner: string) => ( +

• {intl.formatMessage(messages.unknownLearner, { learner })}

+ )) + ) + }); + } + }, + onError: () => { + showModal({ + message: learner.isBetaTester ? intl.formatMessage(messages.removeBetaTesterError) : intl.formatMessage(messages.addBetaTesterError), + variant: 'danger', + confirmText: intl.formatMessage(messages.closeButton), + }); + } + }); + + onClose(); + }, [updateBetaTester, learner.username, learner.isBetaTester, addAlert, intl, showModal, onClose]); + + useEffect(() => { + if (isOpen && !learner.isBetaTester) { + handleUpdateBetaTester(); + } + }, [handleUpdateBetaTester, isOpen, learner.isBetaTester]); + + // Only show modal for removing beta testers (requires confirmation) + if (!isOpen || !learner.isBetaTester) { + return null; + } + + return ( + + +

{intl.formatMessage(messages.removeBetaTesterTitle)}

+
+ +

{intl.formatMessage(messages.removeBetaTesterDescription)}

+
+ + + + +
+ ); +}; + +export default UpdateBetaTesterModal; diff --git a/src/enrollments/data/api.ts b/src/enrollments/data/api.ts index 06f05287..98c7d628 100644 --- a/src/enrollments/data/api.ts +++ b/src/enrollments/data/api.ts @@ -1,6 +1,6 @@ import { camelCaseObject, getAuthenticatedHttpClient, snakeCaseObject } from '@openedx/frontend-base'; import { getApiBaseUrl } from '@src/data/api'; -import { EnrollmentsParams, EnrollmentStatusResponse, EnrolledLearner, UpdateEnrollmentsParams } from '@src/enrollments/types'; +import { EnrollmentsParams, EnrollmentStatusResponse, EnrolledLearner, UpdateEnrollmentsParams, UpdateBetaTestersParams, UpdateEnrollmentsResponse, UpdateBetaTestersResponse } from '@src/enrollments/types'; import { DataList } from '@src/types'; export const getEnrollments = async ( @@ -41,10 +41,23 @@ export const getEnrollmentStatus = async ( export const updateEnrollments = async ( courseId: string, params: UpdateEnrollmentsParams -): Promise => { +): Promise => { const snakeCaseParams = snakeCaseObject(params); - await getAuthenticatedHttpClient().post( + const { data } = await getAuthenticatedHttpClient().post( `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/enrollments/modify`, snakeCaseParams ); + return camelCaseObject(data); +}; + +export const updateBetaTesters = async ( + courseId: string, + params: UpdateBetaTestersParams +): Promise => { + const snakeCaseParams = snakeCaseObject(params); + const { data } = await getAuthenticatedHttpClient().post( + `${getApiBaseUrl()}/api/instructor/v2/courses/${courseId}/beta_testers/modify`, + snakeCaseParams + ); + return camelCaseObject(data); }; diff --git a/src/enrollments/data/apiHook.test.tsx b/src/enrollments/data/apiHook.test.tsx index 399910da..c1991ac2 100644 --- a/src/enrollments/data/apiHook.test.tsx +++ b/src/enrollments/data/apiHook.test.tsx @@ -1,7 +1,7 @@ import { renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { getEnrollments, getEnrollmentStatus, updateEnrollments } from '@src/enrollments/data/api'; -import { useEnrollments, useEnrollmentByUserId, useUpdateEnrollments } from '@src/enrollments/data/apiHook'; +import { getEnrollments, getEnrollmentStatus, updateBetaTesters, updateEnrollments } from '@src/enrollments/data/api'; +import { useEnrollments, useEnrollmentByUserId, useUpdateEnrollments, useUpdateBetaTesters } from '@src/enrollments/data/apiHook'; import { EnrollmentsParams } from '@src/enrollments/types'; jest.mock('@src/enrollments/data/api'); @@ -9,6 +9,7 @@ jest.mock('@src/enrollments/data/api'); const mockGetEnrollments = getEnrollments as jest.MockedFunction; const mockGetEnrollmentStatus = getEnrollmentStatus as jest.MockedFunction; const mockPostUpdateEnrollments = updateEnrollments as jest.MockedFunction; +const mockPostUpdateBetaTesters = updateBetaTesters as jest.MockedFunction; const mockEnrollmentsData = { count: 2, @@ -304,7 +305,7 @@ describe('enrollments api hooks', () => { const courseId = 'course-v1:edX+Test+2023'; it('calls updateEnrollments and succeeds', async () => { - mockPostUpdateEnrollments.mockResolvedValue(undefined); + mockPostUpdateEnrollments.mockResolvedValue({ results: [{ identifier: 'student1', invalidIdentifier: false }] }); const { result } = renderHook(() => useUpdateEnrollments(courseId), { wrapper: createWrapper(), @@ -338,4 +339,43 @@ describe('enrollments api hooks', () => { expect(result.current.error).toBe(error); }); }); + + describe('useUpdateBetaTesters', () => { + const courseId = 'course-v1:edX+Test+2023'; + + it('calls updateEnrollments and succeeds', async () => { + mockPostUpdateBetaTesters.mockResolvedValue({ results: [{ identifier: 'student1', userDoesNotExist: false }] }); + + const { result } = renderHook(() => useUpdateBetaTesters(courseId), { + wrapper: createWrapper(), + }); + + const params = { identifier: ['student1'], action: 'add' as const }; + result.current.mutate(params); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockPostUpdateBetaTesters).toHaveBeenCalledWith(courseId, params); + }); + + it('handles mutation error', async () => { + const error = new Error('Failed to update'); + mockPostUpdateBetaTesters.mockRejectedValue(error); + + const { result } = renderHook(() => useUpdateBetaTesters(courseId), { + wrapper: createWrapper(), + }); + + const params = { identifier: ['student2'], action: 'add' as const }; + result.current.mutate(params); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + expect(mockPostUpdateBetaTesters).toHaveBeenCalledWith(courseId, params); + expect(result.current.error).toBe(error); + }); + }); }); diff --git a/src/enrollments/data/apiHook.ts b/src/enrollments/data/apiHook.ts index eb8d0985..2233c24f 100644 --- a/src/enrollments/data/apiHook.ts +++ b/src/enrollments/data/apiHook.ts @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { getEnrollments, getEnrollmentStatus, updateEnrollments } from '@src/enrollments/data/api'; +import { getEnrollments, getEnrollmentStatus, updateBetaTesters, updateEnrollments } from '@src/enrollments/data/api'; import { enrollmentsQueryKeys } from '@src/enrollments/data/queryKeys'; -import { EnrollmentsParams, UpdateEnrollmentsParams } from '@src/enrollments/types'; +import { EnrollmentsParams, UpdateBetaTestersParams, UpdateEnrollmentsParams } from '@src/enrollments/types'; export const useEnrollments = (courseId: string, params: EnrollmentsParams) => ( useQuery({ @@ -28,3 +28,13 @@ export const useUpdateEnrollments = (courseId: string) => { }, })); }; + +export const useUpdateBetaTesters = (courseId: string) => { + const queryClient = useQueryClient(); + return (useMutation({ + mutationFn: (params: UpdateBetaTestersParams) => updateBetaTesters(courseId, params), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: enrollmentsQueryKeys.byCourse(courseId) }); + }, + })); +}; diff --git a/src/enrollments/messages.ts b/src/enrollments/messages.ts index e7bbadf0..5fffbe6f 100644 --- a/src/enrollments/messages.ts +++ b/src/enrollments/messages.ts @@ -63,7 +63,7 @@ const messages = defineMessages({ }, addLearnerInstructions: { id: 'instruct.enrollments.modals.checkEnrollmentStatus.addLearnerInstructions', - defaultMessage: 'Learner\'s My Open edX email address or username', + 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 enroll learners to the course', }, enrollmentStatusPlaceholder: { @@ -91,6 +91,16 @@ const messages = defineMessages({ defaultMessage: 'Change Beta Tester Status', description: 'Alt text for change beta tester status icon button', }, + grantBetaTester: { + id: 'instruct.enrollments.grantBetaTester', + defaultMessage: 'Grant Beta Tester Role', + description: 'Menu option to grant beta tester status', + }, + revokeBetaTester: { + id: 'instruct.enrollments.revokeBetaTester', + defaultMessage: 'Remove Beta Tester Role', + description: 'Menu option to revoke beta tester status', + }, allEnrollees: { id: 'instruct.enrollments.allEnrollees', defaultMessage: 'All Enrollees', @@ -170,6 +180,51 @@ const messages = defineMessages({ id: 'instruct.enrollments.modals.enrollLearnerNotFoundError', defaultMessage: 'One or more learners were not found. Please check the email addresses or usernames and try again.', description: 'Error message displayed when enrolling learners fails due to learner not found', + }, + addBetaTestersInstructions: { + id: 'instruct.enrollments.modals.addBetaTesters.addBetaTestersInstructions', + defaultMessage: 'Enter email addresses and/or usernames separated by new lines or commas. Note: Users must have an activated My Open edX account before they can be enrolled as beta testers.', + description: 'Instructions for adding beta testers to the course', + }, + failedEnrollLearners: { + id: 'instruct.enrollments.modals.enrollLearners.failedEnrollLearners', + defaultMessage: 'The following usernames and/or email addresses are invalid. All other learners have been enrolled.', + description: 'Message displaying the learners that could not be enrolled', + }, + unknownLearner: { + id: 'instruct.enrollments.unknownLearner', + defaultMessage: 'Unknown learner: {learner}', + description: 'Displayed when a learner does not have a full name or username available', + }, + removeBetaTesterError: { + id: 'instruct.enrollments.modals.removeBetaTesters.removeBetaTesterError', + defaultMessage: 'Error removing user as beta tester.', + description: 'Error message displayed when removing beta testers fails', + }, + failedBetaTesters: { + id: 'instruct.enrollments.modals.addBetaTesters.failedBetaTesters', + defaultMessage: 'The following usernames and/or email addresses are invalid. All other beta testers have been added.', + description: 'Message displaying the learners that could not be added as beta testers', + }, + addBetaTesterError: { + id: 'instruct.enrollments.modals.addBetaTesters.addBetaTesterError', + defaultMessage: 'Error adding users as beta testers.', + description: 'Error message displayed when adding beta testers fails', + }, + removeBetaTesterTitle: { + id: 'instruct.enrollments.modals.removeBetaTester', + defaultMessage: 'Revoke access?', + description: 'Title for remove beta tester modal', + }, + removeBetaTesterDescription: { + id: 'instruct.enrollments.modals.removeBetaTesterDescription', + defaultMessage: 'Revoke Beta Tester access?', + description: 'Description for remove beta tester modal', + }, + revoke: { + id: 'instruct.enrollments.revoke', + defaultMessage: 'Revoke', + description: 'Button label for revoking access', } }); diff --git a/src/enrollments/types.ts b/src/enrollments/types.ts index 0ff205c4..3ed0c0f4 100644 --- a/src/enrollments/types.ts +++ b/src/enrollments/types.ts @@ -20,3 +20,24 @@ export interface UpdateEnrollmentsParams { autoEnroll?: boolean, emailStudents?: boolean, } + +export interface UpdateBetaTestersParams { + identifier: string[], + action: 'add' | 'remove', + autoEnroll?: boolean, + emailStudents?: boolean, +} + +export interface UpdateEnrollmentsResponse { + results: { + identifier: string, + invalidIdentifier: boolean, + }[], +} + +export interface UpdateBetaTestersResponse { + results: { + identifier: string, + userDoesNotExist: boolean, + }[], +}