-
Notifications
You must be signed in to change notification settings - Fork 18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: add course reset tab to learner's information #376
Changes from 4 commits
27b858f
0738d4d
4dc16cd
d202283
a1d41ff
1cd0f79
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,178 @@ | ||
import { | ||
Alert, AlertModal, Button, useToggle, ActionRow, | ||
} from '@edx/paragon'; | ||
import PropTypes from 'prop-types'; | ||
import React, { 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(() => { | ||
// check if there is an enqueued or in progress course reset | ||
const shouldPoll = courseResetData.some((course) => { | ||
const status = course.status.toLowerCase(); | ||
return status.includes('in progress') || status.includes('enqueued'); | ||
}); | ||
|
||
let intervalId; | ||
if (shouldPoll) { | ||
intervalId = setInterval(async () => { | ||
const data = await getLearnerCourseResetList(username); | ||
setCourseResetData(data); | ||
}, POLLING_INTERVAL); | ||
} | ||
return () => clearInterval(intervalId); | ||
}, [courseResetData]); | ||
|
||
const handleSubmit = 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(); | ||
}; | ||
|
||
useEffect(() => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this useEffect(() => {
fetchData();
if (data)
...(above code)
else(error)
}, []); There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you explain what you mean by For the |
||
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); | ||
} | ||
} | ||
}; | ||
|
||
fetchData(); | ||
return () => { | ||
isMounted = false; | ||
}; | ||
}, []); | ||
|
||
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" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is this needed? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Apparently, this is needed for the table style. I'm sorry for the confusion |
||
/> | ||
</section> | ||
); | ||
} | ||
|
||
CourseReset.propTypes = { | ||
username: PropTypes.string.isRequired, | ||
intl: intlShape.isRequired, | ||
}; | ||
|
||
export default injectIntl(CourseReset); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,161 @@ | ||
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(); | ||
}); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
write it with
useCallback
.