Skip to content

Commit

Permalink
Merge pull request #376 from openedx/hajorg/au-1845-whole-course-reset
Browse files Browse the repository at this point in the history
feat: add course reset tab to learner's information
  • Loading branch information
hajorg authored Mar 14, 2024
2 parents b70c6d2 + 1cd0f79 commit 94aeaff
Show file tree
Hide file tree
Showing 8 changed files with 522 additions and 0 deletions.
184 changes: 184 additions & 0 deletions src/users/CourseReset.jsx
Original file line number Diff line number Diff line change
@@ -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 = (
<>
<Button
variant="outline-primary"
className="reset-btn"
onClick={open}
>
Reset
</Button>

<AlertModal
title="Warning"
isOpen={isOpen}
onClose={close}
variant="warning"
footerNode={(
<ActionRow>
<Button
variant="primary"
onClick={() => handleSubmit(data.course_id)}
>
Yes
</Button>
<Button variant="tertiary" onClick={close}>
No
</Button>
</ActionRow>
)}
>
<p>
<FormattedMessage
id="course.reset.alert.warning"
defaultMessage="Are you sure? This will erase all of this learner's data for this course. This can only happen once per learner per course."
/>
</p>
</AlertModal>
</>
);
}

if (data.status.toLowerCase().includes('in progress')) {
updatedData.action = (
<Button type="Submit" disabled>
Processing
</Button>
);
}

return updatedData;
});

return (
<section data-testid="course-reset-container">
<h3>Course Reset</h3>
{error && (
<Alert
variant="danger"
dismissible
onClose={() => {
setError(null);
}}
>
{error}
</Alert>
)}
<Table
columns={[
{
Header: intl.formatMessage(messages.recordTableHeaderCourseName),
accessor: 'displayName',
},
{
Header: intl.formatMessage(messages.recordTableHeaderStatus),
accessor: 'status',
},
{
Header: 'Action',
accessor: 'action',
},
]}
data={renderResetData}
styleName="custom-table"
/>
</section>
);
}

CourseReset.propTypes = {
username: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};

export default injectIntl(CourseReset);
162 changes: 162 additions & 0 deletions src/users/CourseReset.test.jsx
Original file line number Diff line number Diff line change
@@ -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) => (
<IntlProvider locale="en">
<CourseReset {...props} />
</IntlProvider>
);

describe('CourseReset', () => {
it('renders the component with the provided user prop', () => {
const user = 'John Doe';
const screen = render(<CourseResetWrapper username={user} />);
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(<CourseResetWrapper username={user} />);
});
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(<CourseResetWrapper username={user} />);
});

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(<CourseResetWrapper username={user} />);
});
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(<CourseResetWrapper username={user} />);
});

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();
});
});
});
5 changes: 5 additions & 0 deletions src/users/LearnerInformation.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -57,6 +58,10 @@ export default function LearnerInformation({
<br />
<LearnerRecords username={user.username} />
</Tab>
<Tab eventKey="course-reset" title="Course Reset">
<br />
<CourseReset username={user.username} />
</Tab>
</Tabs>
</>
);
Expand Down
Loading

0 comments on commit 94aeaff

Please sign in to comment.