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}`;