diff --git a/src/users/CourseReset.jsx b/src/users/CourseReset.jsx new file mode 100644 index 000000000..938415933 --- /dev/null +++ b/src/users/CourseReset.jsx @@ -0,0 +1,184 @@ +import { + Alert, AlertModal, Button, useToggle, ActionRow, +} from '@edx/paragon'; +import PropTypes from 'prop-types'; +import React, { useCallback, useEffect, useState } from 'react'; +import { + injectIntl, + intlShape, + FormattedMessage, +} from '@edx/frontend-platform/i18n'; +import Table from '../components/Table'; + +import { getLearnerCourseResetList, postCourseReset } from './data/api'; +import messages from './messages'; + +function CourseReset({ username, intl }) { + const [courseResetData, setCourseResetData] = useState([]); + const [error, setError] = useState(''); + const [isOpen, open, close] = useToggle(false); + const POLLING_INTERVAL = 10000; + + useEffect(() => { + let isMounted = true; + + const fetchData = async () => { + const data = await getLearnerCourseResetList(username); + if (isMounted) { + if (data.length) { + setCourseResetData(data); + } else if (data && data.errors) { + setCourseResetData([]); + setError(data.errors[0]?.text); + } + } + }; + + const shouldPoll = courseResetData.some((data) => { + const status = data.status.toLowerCase(); + return status.includes('in progress') || status.includes('enqueued'); + }); + + let intervalId; + const initializeAndPoll = async () => { + if (!courseResetData.length) { + await fetchData(); // Initial data fetch + } + + if (shouldPoll) { + intervalId = setInterval(() => { + fetchData(); + }, POLLING_INTERVAL); + } + }; + + if (isMounted) { + initializeAndPoll(); // Execute initial fetch and start polling if necessary + } + + return () => { + isMounted = false; + clearInterval(intervalId); + }; + }, [courseResetData, username]); + + const handleSubmit = useCallback(async (courseID) => { + setError(null); + const data = await postCourseReset(username, courseID); + if (data && !data.errors) { + const updatedCourseResetData = courseResetData.map((course) => { + if (course.course_id === data.course_id) { + return data; + } + return course; + }); + setCourseResetData(updatedCourseResetData); + } + if (data && data.errors) { + setError(data.errors[0].text); + } + close(); + }, [username, courseResetData]); + + const renderResetData = courseResetData.map((data) => { + const updatedData = { + displayName: data.display_name, + courseId: data.course_id, + status: data.status, + action: 'Unavailable', + }; + + if (data.can_reset) { + updatedData.action = ( + <> + + + + + + + )} + > +

+ +

+
+ + ); + } + + if (data.status.toLowerCase().includes('in progress')) { + updatedData.action = ( + + ); + } + + return updatedData; + }); + + return ( +
+

Course Reset

+ {error && ( + { + setError(null); + }} + > + {error} + + )} + + + ); +} + +CourseReset.propTypes = { + username: PropTypes.string.isRequired, + intl: intlShape.isRequired, +}; + +export default injectIntl(CourseReset); diff --git a/src/users/CourseReset.test.jsx b/src/users/CourseReset.test.jsx new file mode 100644 index 000000000..55caceccf --- /dev/null +++ b/src/users/CourseReset.test.jsx @@ -0,0 +1,162 @@ +import React from 'react'; +import { act, render, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import CourseReset from './CourseReset'; +import * as api from './data/api'; +import { expectedGetData, expectedPostData } from './data/test/courseReset'; + +const CourseResetWrapper = (props) => ( + + + +); + +describe('CourseReset', () => { + it('renders the component with the provided user prop', () => { + const user = 'John Doe'; + const screen = render(); + const container = screen.getByTestId('course-reset-container'); + expect(screen).toBeTruthy(); + expect(container).toBeInTheDocument(); + }); + + it('clicks on the reset button and make a post request successfully', async () => { + jest + .spyOn(api, 'getLearnerCourseResetList') + .mockImplementationOnce(() => Promise.resolve(expectedGetData)); + const postRequest = jest + .spyOn(api, 'postCourseReset') + .mockImplementationOnce(() => Promise.resolve(expectedPostData)); + + const user = 'John Doe'; + let screen; + + await waitFor(() => { + screen = render(); + }); + const btn = screen.getByText('Reset', { selector: 'button' }); + userEvent.click(btn); + await waitFor(() => { + const submitButton = screen.getByText(/Yes/); + userEvent.click(submitButton); + expect(screen.getByText(/Yes/)).toBeInTheDocument(); + }); + + userEvent.click(screen.queryByText(/Yes/)); + + await waitFor(() => { + expect(screen.queryByText(/Warning/)).not.toBeInTheDocument(); + }); + expect(postRequest).toHaveBeenCalled(); + }); + + it('polls new data', async () => { + jest.useFakeTimers(); + const data = [{ + course_id: 'course-v1:edX+DemoX+Demo_Course', + display_name: 'Demonstration Course', + can_reset: false, + status: 'In progress - Created 2024-02-28 11:29:06.318091+00:00 by edx', + }]; + + const updatedData = [{ + course_id: 'course-v1:edX+DemoX+Demo_Course', + display_name: 'Demonstration Course', + can_reset: false, + status: 'Completed by Support 2024-02-28 11:29:06.318091+00:00 by edx', + }]; + + jest + .spyOn(api, 'getLearnerCourseResetList') + .mockImplementationOnce(() => Promise.resolve(data)) + .mockImplementationOnce(() => Promise.resolve(updatedData)); + const user = 'John Doe'; + let screen; + await act(async () => { + screen = render(); + }); + + const inProgressText = screen.getByText(/in progress/i); + expect(inProgressText).toBeInTheDocument(); + + jest.advanceTimersByTime(10000); + + const completedText = await screen.findByText(/Completed by/i); + expect(completedText).toBeInTheDocument(); + }); + + it('returns an empty table if it cannot fetch course reset list', async () => { + jest + .spyOn(api, 'getLearnerCourseResetList') + .mockResolvedValueOnce({ + errors: [ + { + code: null, + dismissible: true, + text: 'An error occurred fetching course reset list for user', + type: 'danger', + }, + ], + }); + + let screen; + const user = 'john'; + await act(async () => { + screen = render(); + }); + const alertText = screen.getByText(/An error occurred fetching course reset list for user/); + expect(alertText).toBeInTheDocument(); + }); + + it('returns an error when resetting a course', async () => { + const user = 'John Doe'; + let screen; + + jest.spyOn(api, 'getLearnerCourseResetList').mockResolvedValueOnce(expectedGetData); + jest + .spyOn(api, 'postCourseReset') + .mockResolvedValueOnce({ + errors: [ + { + code: null, + dismissible: true, + text: 'An error occurred resetting course for user', + type: 'danger', + topic: 'credentials', + }, + ], + }); + + await act(async () => { + screen = render(); + }); + + await waitFor(() => { + const btn = screen.getByText('Reset', { selector: 'button' }); + userEvent.click(btn); + }); + + await waitFor(() => { + const submitButton = screen.getByText(/Yes/); + userEvent.click(submitButton); + expect(screen.getByText(/Yes/)).toBeInTheDocument(); + }); + + userEvent.click(screen.queryByText(/Yes/)); + + await waitFor(() => { + expect(screen.queryByText(/Warning/)).not.toBeInTheDocument(); + }); + + expect(api.postCourseReset).toHaveBeenCalled(); + const alertText = screen.getByText(/An error occurred resetting course for user/); + expect(alertText).toBeInTheDocument(); + const dismiss = screen.getByText(/dismiss/i); + userEvent.click(dismiss); + await waitFor(() => { + expect(alertText).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/users/LearnerInformation.jsx b/src/users/LearnerInformation.jsx index fda37b1c9..d7ccdc594 100644 --- a/src/users/LearnerInformation.jsx +++ b/src/users/LearnerInformation.jsx @@ -8,6 +8,7 @@ import EntitlementsAndEnrollmentsContainer from './EntitlementsAndEnrollmentsCon import LearnerCredentials from './LearnerCredentials'; import LearnerRecords from './LearnerRecords'; import LearnerPurchases from './LearnerPurchases'; +import CourseReset from './CourseReset'; export default function LearnerInformation({ user, changeHandler, @@ -57,6 +58,10 @@ export default function LearnerInformation({
+ +
+ +
); diff --git a/src/users/LearnerInformation.test.jsx b/src/users/LearnerInformation.test.jsx index c06347db5..703bdb2d4 100644 --- a/src/users/LearnerInformation.test.jsx +++ b/src/users/LearnerInformation.test.jsx @@ -61,6 +61,7 @@ describe('Learners and Enrollments component', () => { expect(tabs.at(3).text()).toEqual('SSO/License Info'); expect(tabs.at(4).text()).toEqual('Learner Credentials'); expect(tabs.at(5).text()).toEqual('Learner Records'); + expect(tabs.at(6).text()).toEqual('Course Reset'); }); it('Account Information Tab', () => { @@ -180,4 +181,26 @@ describe('Learners and Enrollments component', () => { expect.stringContaining('Learner Records'), ); }); + + it('Course Reset Tab', () => { + let tabs = wrapper.find('nav.nav-tabs a'); + + tabs.at(6).simulate('click'); + tabs = wrapper.find('nav.nav-tabs a'); + expect(tabs.at(0).html()).not.toEqual(expect.stringContaining('active')); + expect(tabs.at(1).html()).not.toEqual(expect.stringContaining('active')); + expect(tabs.at(2).html()).not.toEqual(expect.stringContaining('active')); + expect(tabs.at(3).html()).not.toEqual(expect.stringContaining('active')); + expect(tabs.at(4).html()).not.toEqual(expect.stringContaining('active')); + expect(tabs.at(5).html()).not.toEqual(expect.stringContaining('active')); + expect(tabs.at(6).html()).toEqual(expect.stringContaining('active')); + + const records = wrapper.find( + '.tab-content div#learner-information-tabpane-course-reset', + ); + expect(records.html()).toEqual(expect.stringContaining('active')); + expect(records.html()).toEqual( + expect.stringContaining('Course Reset'), + ); + }); }); diff --git a/src/users/data/api.js b/src/users/data/api.js index 4e20348a8..45c38c9dd 100644 --- a/src/users/data/api.js +++ b/src/users/data/api.js @@ -781,3 +781,43 @@ export async function getOrderHistory(username) { }; } } + +export async function getLearnerCourseResetList(username) { + try { + const { data } = await getAuthenticatedHttpClient().get(AppUrls.courseResetUrl(username)); + return data; + } catch (error) { + return { + errors: [ + { + code: null, + dismissible: true, + text: 'There was an error retrieving list of course reset for the user', + type: 'danger', + topic: 'courseReset', + }, + ], + }; + } +} + +export async function postCourseReset(username, courseID) { + try { + const { data } = await getAuthenticatedHttpClient().post(AppUrls.courseResetUrl(username), { + course_id: courseID, + }); + return data; + } catch (error) { + return { + errors: [ + { + code: null, + dismissible: true, + text: 'An error occurred when resetting user\'s course', + type: 'danger', + topic: 'courseReset', + }, + ], + }; + } +} diff --git a/src/users/data/api.test.js b/src/users/data/api.test.js index 890da6b1d..ec5c5ba31 100644 --- a/src/users/data/api.test.js +++ b/src/users/data/api.test.js @@ -36,6 +36,7 @@ describe('API', () => { const programRecordsUrl = urls.getLearnerRecordsUrl(); const retirementApiUrl = urls.userRetirementUrl(); const orderHistoryApiUrl = urls.getOrderHistoryUrl(); + const courseResetUrl = urls.courseResetUrl(testUsername); let mockAdapter; @@ -1267,4 +1268,82 @@ describe('API', () => { expect(result).toEqual(expectedError); }); }); + + describe('Course Reset', () => { + it('should return course reset list for a user', async () => { + const expectedData = [ + { + course_id: 'course-v1:edX+DemoX+Demo_Course', + display_name: 'Demonstration Course', + can_reset: false, + status: 'Enqueued - Created 2024-02-28 11:29:06.318091+00:00 by edx', + }, + { + course_id: 'course-v1:EdxOrg+EDX101+2024_Q1', + display_name: 'Intro to edx', + can_reset: true, + status: 'Available', + }, + ]; + + mockAdapter.onGet(courseResetUrl).reply(200, expectedData); + + const result = await api.getLearnerCourseResetList(testUsername); + + expect(result).toEqual(expectedData); + }); + + it('should return an empty array when an error occurs', async () => { + const expectedError = { + errors: [ + { + code: null, + dismissible: true, + text: 'There was an error retrieving list of course reset for the user', + type: 'danger', + topic: 'courseReset', + }, + ], + }; + mockAdapter.onGet().reply(() => throwError(404, '')); + + const result = await api.getLearnerCourseResetList(testUsername); + + expect(result).toEqual(expectedError); + }); + + it('should post a course reset', async () => { + const expectedData = { + course_id: 'course-v1:EdxOrg+EDX101+2024_Q1', + display_name: 'Intro to edx', + can_reset: false, + status: 'Enqueued - Created 2024-02-28 11:29:06.318091+00:00 by edx', + }; + + mockAdapter.onPost(courseResetUrl).reply(201, expectedData); + + const result = await api.postCourseReset(testUsername, 'course-v1:EdxOrg+EDX101+2024_Q1'); + + expect(result).toEqual(expectedData); + }); + }); + + it('returns a 400 error', async () => { + const expectedError = { + errors: [ + { + code: null, + dismissible: true, + text: 'An error occurred when resetting user\'s course', + type: 'danger', + topic: 'courseReset', + }, + ], + }; + mockAdapter.onPost().reply(() => throwError(400, '')); + + const result = await api.postCourseReset(testUsername); + + expect(result).toEqual(expectedError); + }); }); diff --git a/src/users/data/test/courseReset.js b/src/users/data/test/courseReset.js new file mode 100644 index 000000000..12078ae8e --- /dev/null +++ b/src/users/data/test/courseReset.js @@ -0,0 +1,27 @@ +export const expectedGetData = [ + { + course_id: 'course-v1:edX+DemoX+Demo_Course', + display_name: 'Demonstration Course', + can_reset: false, + status: 'Enqueued - Created 2024-02-28 11:29:06.318091+00:00 by edx', + }, + { + course_id: 'course-v1:EdxOrg+EDX101+2024_Q1', + display_name: 'Intro to edx', + can_reset: true, + status: 'Available', + }, + { + course_id: 'course-v1:EdxOrg+EDX201+2024_Q2', + display_name: 'Intro to new course', + can_reset: false, + status: 'in progress', + }, +]; + +export const expectedPostData = { + course_id: 'course-v1:EdxOrg+EDX101+2024_Q1', + display_name: 'Intro to edx', + can_reset: false, + status: 'Enqueued - Created 2024-02-28 11:29:06.318091+00:00 by edx', +}; diff --git a/src/users/data/urls.js b/src/users/data/urls.js index 1a45ffad8..ea63a5d29 100644 --- a/src/users/data/urls.js +++ b/src/users/data/urls.js @@ -111,3 +111,5 @@ export const getUserCredentialsUrl = () => `${CREDENTIALS_BASE_URL}/api/v2/crede export const getLearnerRecordsUrl = () => `${CREDENTIALS_BASE_URL}/records/api/v1/program_records`; export const getOrderHistoryUrl = () => `${ECOMMERCE_BASE_URL}/api/v2/orders`; + +export const courseResetUrl = (username) => `${LMS_BASE_URL}/support/course_reset/${username}`;