-
Notifications
You must be signed in to change notification settings - Fork 18
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #376 from openedx/hajorg/au-1845-whole-course-reset
feat: add course reset tab to learner's information
- Loading branch information
Showing
8 changed files
with
522 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.