Merge pull request #19986 from open-craft/kshitij/report-status

Inline problem response report status
This commit is contained in:
David Ormsbee
2020-08-13 12:51:55 -04:00
committed by GitHub
21 changed files with 583 additions and 90 deletions

View File

@@ -18,7 +18,7 @@ export const buildBlockTree = (blocks, excludeBlockTypes) => {
return blockTree(blocks.root, null);
};
const blocks = (state = {}, action) => {
export const blocks = (state = {}, action) => {
switch (action.type) {
case courseBlocksActions.fetch.SUCCESS:
return buildBlockTree(action.blocks, action.excludeBlockTypes);
@@ -27,7 +27,7 @@ const blocks = (state = {}, action) => {
}
};
const selectedBlock = (state = null, action) => {
export const selectedBlock = (state = null, action) => {
switch (action.type) {
case courseBlocksActions.SELECT_BLOCK:
return action.blockId;
@@ -37,7 +37,7 @@ const selectedBlock = (state = null, action) => {
};
const rootBlock = (state = null, action) => {
export const rootBlock = (state = null, action) => {
switch (action.type) {
case courseBlocksActions.fetch.SUCCESS:
return action.blocks.root;

View File

@@ -8,4 +8,7 @@ module.exports = {
},
},
},
rules: {
'import/prefer-default-export': 'off',
},
};

View File

@@ -1,13 +1,15 @@
/* global gettext */
import { Button } from '@edx/paragon';
import { BlockBrowser } from 'BlockBrowser';
import BlockBrowserContainer from 'BlockBrowser/components/BlockBrowser/BlockBrowserContainer';
import * as PropTypes from 'prop-types';
import * as React from 'react';
import { ReportStatusContainer } from '../ReportStatus/ReportStatusContainer';
export default class Main extends React.Component {
constructor(props) {
super(props);
this.handleToggleDropdown = this.handleToggleDropdown.bind(this);
this.initiateReportGeneration = this.initiateReportGeneration.bind(this);
this.state = {
showDropdown: false,
};
@@ -22,19 +24,39 @@ export default class Main extends React.Component {
this.setState({ showDropdown: false });
}
initiateReportGeneration() {
this.props.createProblemResponsesReportTask(
this.props.problemResponsesEndpoint,
this.props.taskStatusEndpoint,
this.props.selectedBlock,
);
}
render() {
const { selectedBlock, onSelectBlock } = this.props;
return (
<div className="problem-browser">
<Button onClick={this.handleToggleDropdown} label={gettext('Select a section or problem')} />
<input type="text" name="problem-location" value={selectedBlock} disabled />
{this.state.showDropdown &&
<BlockBrowser onSelectBlock={(blockId) => {
this.hideDropdown();
onSelectBlock(blockId);
}}
/>}
<div className="problem-browser-container">
<div className="problem-browser">
<Button
onClick={this.handleToggleDropdown}
label={gettext('Select a section or problem')}
/>
<input type="text" name="problem-location" value={selectedBlock} disabled />
{this.state.showDropdown &&
<BlockBrowserContainer
onSelectBlock={(blockId) => {
this.hideDropdown();
onSelectBlock(blockId);
}}
/>}
<Button
onClick={this.initiateReportGeneration}
name="list-problem-responses-csv"
label={gettext('Create a report of problem responses')}
/>
</div>
<ReportStatusContainer />
</div>
);
}
@@ -42,13 +64,17 @@ export default class Main extends React.Component {
Main.propTypes = {
courseId: PropTypes.string.isRequired,
createProblemResponsesReportTask: PropTypes.func.isRequired,
excludeBlockTypes: PropTypes.arrayOf(PropTypes.string),
fetchCourseBlocks: PropTypes.func.isRequired,
problemResponsesEndpoint: PropTypes.string.isRequired,
onSelectBlock: PropTypes.func.isRequired,
selectedBlock: PropTypes.string,
taskStatusEndpoint: PropTypes.string.isRequired,
};
Main.defaultProps = {
excludeBlockTypes: null,
selectedBlock: null,
selectedBlock: '',
timeout: null,
};

View File

@@ -1,25 +1,34 @@
/* global jest,test,describe,expect */
import { Button } from '@edx/paragon';
import { BlockBrowser } from 'BlockBrowser';
import BlockBrowserContainer from 'BlockBrowser/components/BlockBrowser/BlockBrowserContainer';
import { Provider } from 'react-redux';
import { shallow } from 'enzyme';
import React from 'react';
import renderer from 'react-test-renderer';
import store from '../../data/store';
import Main from './Main';
describe('ProblemBrowser Main component', () => {
const courseId = 'testcourse';
const problemResponsesEndpoint = '/api/problem_responses/';
const taskStatusEndpoint = '/api/task_status/';
const excludedBlockTypes = [];
test('render with basic parameters', () => {
const component = renderer.create(
<Main
courseId={courseId}
excludeBlockTypes={excludedBlockTypes}
fetchCourseBlocks={jest.fn()}
onSelectBlock={jest.fn()}
selectedBlock={null}
/>,
<Provider store={store}>
<Main
courseId={courseId}
createProblemResponsesReportTask={jest.fn()}
excludeBlockTypes={excludedBlockTypes}
fetchCourseBlocks={jest.fn()}
problemResponsesEndpoint={problemResponsesEndpoint}
onSelectBlock={jest.fn()}
selectedBlock={null}
taskStatusEndpoint={taskStatusEndpoint}
/>
</Provider>,
);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
@@ -27,13 +36,18 @@ describe('ProblemBrowser Main component', () => {
test('render with selected block', () => {
const component = renderer.create(
<Main
courseId={courseId}
excludeBlockTypes={excludedBlockTypes}
fetchCourseBlocks={jest.fn()}
onSelectBlock={jest.fn()}
selectedBlock={'some-selected-block'}
/>,
<Provider store={store}>
<Main
courseId={courseId}
createProblemResponsesReportTask={jest.fn()}
excludeBlockTypes={excludedBlockTypes}
fetchCourseBlocks={jest.fn()}
problemResponsesEndpoint={problemResponsesEndpoint}
onSelectBlock={jest.fn()}
selectedBlock={'some-selected-block'}
taskStatusEndpoint={taskStatusEndpoint}
/>
</Provider>,
);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
@@ -42,15 +56,20 @@ describe('ProblemBrowser Main component', () => {
test('fetch course block on toggling dropdown', () => {
const fetchCourseBlocksMock = jest.fn();
const component = renderer.create(
<Main
courseId={courseId}
excludeBlockTypes={excludedBlockTypes}
fetchCourseBlocks={fetchCourseBlocksMock}
onSelectBlock={jest.fn()}
selectedBlock={'some-selected-block'}
/>,
<Provider store={store}>
<Main
courseId={courseId}
createProblemResponsesReportTask={jest.fn()}
excludeBlockTypes={excludedBlockTypes}
fetchCourseBlocks={fetchCourseBlocksMock}
problemResponsesEndpoint={problemResponsesEndpoint}
onSelectBlock={jest.fn()}
selectedBlock={'some-selected-block'}
taskStatusEndpoint={taskStatusEndpoint}
/>
</Provider>,
);
const instance = component.getInstance();
const instance = component.root.children[0].instance;
instance.handleToggleDropdown();
expect(fetchCourseBlocksMock.mock.calls.length).toBe(1);
});
@@ -59,13 +78,17 @@ describe('ProblemBrowser Main component', () => {
const component = shallow(
<Main
courseId={courseId}
createProblemResponsesReportTask={jest.fn()}
excludeBlockTypes={excludedBlockTypes}
fetchCourseBlocks={jest.fn()}
problemResponsesEndpoint={problemResponsesEndpoint}
onSelectBlock={jest.fn()}
selectedBlock={'some-selected-block'}
taskStatusEndpoint={taskStatusEndpoint}
/>,
);
component.find(Button).simulate('click');
expect(component.find(BlockBrowser)).toBeTruthy();
expect(component.find(BlockBrowserContainer).length).toBeFalsy();
component.find(Button).find({ label: 'Select a section or problem' }).simulate('click');
expect(component.find(BlockBrowserContainer).length).toBeTruthy();
});
});

View File

@@ -1,6 +1,6 @@
import { fetchCourseBlocks, selectBlock } from 'BlockBrowser/data/actions/courseBlocks';
import { connect } from 'react-redux';
import { createProblemResponsesReportTask } from '../../data/actions/problemResponses';
import Main from './Main';
const mapStateToProps = state => ({
@@ -10,8 +10,16 @@ const mapStateToProps = state => ({
const mapDispatchToProps = dispatch => ({
onSelectBlock: blockId => dispatch(selectBlock(blockId)),
fetchCourseBlocks: (courseId, excludeBlockTypes) =>
dispatch(fetchCourseBlocks(courseId, excludeBlockTypes)),
fetchCourseBlocks:
(courseId, excludeBlockTypes) =>
dispatch(fetchCourseBlocks(courseId, excludeBlockTypes)),
createProblemResponsesReportTask:
(problemResponsesEndpoint, taskStatusEndpoint, problemLocation) =>
dispatch(
createProblemResponsesReportTask(
problemResponsesEndpoint, taskStatusEndpoint, problemLocation,
),
),
});
const MainContainer = connect(

View File

@@ -2,44 +2,80 @@
exports[`ProblemBrowser Main component render with basic parameters 1`] = `
<div
className="problem-browser"
className="problem-browser-container"
>
<button
className="btn"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
<div
className="problem-browser"
>
Select a section or problem
</button>
<input
disabled={true}
name="problem-location"
type="text"
value={null}
<button
className="btn"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
Select a section or problem
</button>
<input
disabled={true}
name="problem-location"
type="text"
value={null}
/>
<button
className="btn"
name="list-problem-responses-csv"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
Create a report of problem responses
</button>
</div>
<div
aria-live="polite"
className="report-generation-status"
/>
</div>
`;
exports[`ProblemBrowser Main component render with selected block 1`] = `
<div
className="problem-browser"
className="problem-browser-container"
>
<button
className="btn"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
<div
className="problem-browser"
>
Select a section or problem
</button>
<input
disabled={true}
name="problem-location"
type="text"
value="some-selected-block"
<button
className="btn"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
Select a section or problem
</button>
<input
disabled={true}
name="problem-location"
type="text"
value="some-selected-block"
/>
<button
className="btn"
name="list-problem-responses-csv"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
Create a report of problem responses
</button>
</div>
<div
aria-live="polite"
className="report-generation-status"
/>
</div>
`;

View File

@@ -0,0 +1,55 @@
/* global gettext */
import { Icon } from '@edx/paragon';
import classNames from 'classnames';
import * as PropTypes from 'prop-types';
import * as React from 'react';
const ReportStatus = ({ error, succeeded, inProgress, reportPath }) => {
const progressMessage = (
<div className="msg progress">
{gettext('Your report is being generated...')}
<Icon hidden className={['fa', 'fa-refresh', 'fa-spin', 'fa-fw']} />
</div>
);
const successMessage = (
<div className="msg success">
{gettext('Your report has being successfully generated.')}
<a href={reportPath}>
<Icon hidden className={['fa', 'fa-link']} />
{gettext('View Report')}
</a>
</div>
);
const errorMessage = (
<div className={classNames('msg', { error })}>
{error && `${gettext('Error')}: `}
{error}
</div>
);
return (
<div className="report-generation-status" aria-live="polite">
{inProgress && progressMessage}
{error && errorMessage}
{succeeded && successMessage}
</div>
);
};
ReportStatus.propTypes = {
error: PropTypes.string,
succeeded: PropTypes.bool.isRequired,
inProgress: PropTypes.bool.isRequired,
reportPath: PropTypes.string,
};
ReportStatus.defaultProps = {
error: null,
reportPath: null,
reportPreview: null,
reportName: null,
};
export default ReportStatus;

View File

@@ -0,0 +1,48 @@
/* global test,describe,expect */
import React from 'react';
import renderer from 'react-test-renderer';
import ReportStatus from './ReportStatus';
describe('ReportStatus component', () => {
test('render in progress status', () => {
const component = renderer.create(
<ReportStatus
error={null}
inProgress
reportName={null}
reportPath={null}
succeeded={false}
/>,
);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
test('render success status', () => {
const component = renderer.create(
<ReportStatus
error={null}
inProgress={false}
reportName={'some-report-name'}
reportPath={'/some/report/path.csv'}
succeeded
/>,
);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
test('render error status', () => {
const component = renderer.create(
<ReportStatus
error={'some error status'}
inProgress={false}
reportName={null}
reportPath={null}
succeeded={false}
/>,
);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,17 @@
import { connect } from 'react-redux';
import ReportStatus from './ReportStatus';
const mapStateToProps = state => ({
selectedBlock: state.selectedBlock,
error: state.reportStatus.error,
inProgress: state.reportStatus.inProgress,
succeeded: state.reportStatus.succeeded,
reportPath: state.reportStatus.reportPath,
timeout: state.reportStatus.timeout,
});
export const ReportStatusContainer = connect(
mapStateToProps,
)(ReportStatus);
export default ReportStatusContainer;

View File

@@ -0,0 +1,60 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ReportStatus component render error status 1`] = `
<div
aria-live="polite"
className="report-generation-status"
>
<div
className="msg error"
>
Error:
some error status
</div>
</div>
`;
exports[`ReportStatus component render in progress status 1`] = `
<div
aria-live="polite"
className="report-generation-status"
>
<div
className="msg progress"
>
Your report is being generated...
<div>
<span
aria-hidden={true}
className="fa fa-refresh fa-spin fa-fw"
id="Icon2"
/>
</div>
</div>
</div>
`;
exports[`ReportStatus component render success status 1`] = `
<div
aria-live="polite"
className="report-generation-status"
>
<div
className="msg success"
>
Your report has being successfully generated.
<a
href="/some/report/path.csv"
>
<div>
<span
aria-hidden={true}
className="fa fa-link"
id="Icon2"
/>
</div>
View Report
</a>
</div>
</div>
`;

View File

@@ -0,0 +1,4 @@
export const REPORT_GENERATION_REQUEST = 'REPORT_GENERATION_REQUEST';
export const REPORT_GENERATION_SUCCESS = 'REPORT_GENERATION_SUCCESS';
export const REPORT_GENERATION_ERROR = 'REPORT_GENERATION_ERROR';
export const REPORT_GENERATION_REFRESH_STATUS = 'REPORT_GENERATION_REFRESH_STATUS';

View File

@@ -0,0 +1,91 @@
/* global gettext */
import { fetchTaskStatus, initiateProblemResponsesRequest } from '../api/client';
import {
REPORT_GENERATION_ERROR,
REPORT_GENERATION_REQUEST,
REPORT_GENERATION_SUCCESS,
REPORT_GENERATION_REFRESH_STATUS,
} from './constants';
const taskStatusSuccess = (succeeded, inProgress, reportPath, reportName) => ({
type: REPORT_GENERATION_SUCCESS,
succeeded,
inProgress,
reportPath,
reportName,
});
const problemResponsesRequest = blockId => ({
type: REPORT_GENERATION_REQUEST,
blockId,
});
const problemResponsesFailure = error => ({
type: REPORT_GENERATION_ERROR,
error,
});
const problemResponsesRefreshStatus = timeout => ({
type: REPORT_GENERATION_REFRESH_STATUS,
timeout,
});
const getTaskStatus = (endpoint, taskId) => dispatch =>
fetchTaskStatus(endpoint, taskId)
.then((response) => {
if (response.ok) {
return response.json();
}
throw new Error(response);
})
.then(
(statusData) => {
if (statusData.in_progress) {
const timeout = setTimeout(() => dispatch(getTaskStatus(endpoint, taskId)), 2000);
return dispatch(problemResponsesRefreshStatus(timeout));
}
if (statusData.task_state === 'SUCCESS') {
const taskProgress = statusData.task_progress;
const reportPath = taskProgress && taskProgress.report_path;
const reportName = taskProgress && taskProgress.report_name;
return dispatch(
taskStatusSuccess(
true,
statusData.in_progress,
reportPath,
reportName,
),
);
}
return dispatch(problemResponsesFailure(gettext('There was an error generating your report.')));
},
() => dispatch(
problemResponsesFailure(gettext('Unable to get report generation status.')),
),
);
const createProblemResponsesReportTask = (
problemResponsesEndpoint,
taskStatusEndpoint,
blockId,
) => (dispatch) => {
dispatch(problemResponsesRequest(blockId));
initiateProblemResponsesRequest(problemResponsesEndpoint, blockId)
.then((response) => {
if (response.ok) {
return response.json();
}
throw new Error(response);
})
.then(
json => dispatch(getTaskStatus(taskStatusEndpoint, json.task_id)),
() => dispatch(problemResponsesFailure(gettext('Unable to submit request to generate report.'))),
);
};
export {
problemResponsesFailure,
createProblemResponsesReportTask,
problemResponsesRequest,
getTaskStatus,
};

View File

@@ -0,0 +1,33 @@
import 'whatwg-fetch';
import Cookies from 'js-cookie';
const HEADERS = {
Accept: 'application/json',
'X-CSRFToken': Cookies.get('csrftoken'),
};
function initiateProblemResponsesRequest(endpoint, blockId) {
const formData = new FormData();
formData.set('problem_location', blockId);
return fetch(
endpoint, {
credentials: 'same-origin',
method: 'post',
headers: HEADERS,
body: formData,
},
);
}
const fetchTaskStatus = (endpoint, taskId) => fetch(
`${endpoint}/?task_id=${taskId}`, {
credentials: 'same-origin',
method: 'get',
headers: HEADERS,
});
export {
initiateProblemResponsesRequest,
fetchTaskStatus,
};

View File

@@ -0,0 +1,46 @@
import { combineReducers } from 'redux'; // eslint-disable-line
import { blocks, selectedBlock, rootBlock } from 'BlockBrowser/data/reducers'; // eslint-disable-line
import {
REPORT_GENERATION_ERROR,
REPORT_GENERATION_SUCCESS,
REPORT_GENERATION_REFRESH_STATUS,
REPORT_GENERATION_REQUEST,
} from '../actions/constants';
const initialState = {
error: null,
inProgress: false,
succeeded: false,
reportPath: null,
reportName: null,
timeout: null,
};
export const reportStatus = (state = initialState, action) => {
switch (action.type) {
case REPORT_GENERATION_REQUEST:
return initialState;
case REPORT_GENERATION_SUCCESS:
return {
...state,
inProgress: action.inProgress,
succeeded: action.succeeded,
reportPath: action.reportPath,
reportName: action.reportName,
error: null,
};
case REPORT_GENERATION_ERROR:
return { ...state, error: action.error, succeeded: false };
case REPORT_GENERATION_REFRESH_STATUS:
return { ...state, timeout: action.timeout };
default:
return state;
}
};
export default combineReducers({
blocks,
selectedBlock,
rootBlock,
reportStatus,
});

View File

@@ -0,0 +1,15 @@
import { applyMiddleware, createStore } from 'redux';
import thunkMiddleware from 'redux-thunk';
import rootReducer from './reducers';
const configureStore = initialState => createStore(
rootReducer,
initialState,
applyMiddleware(thunkMiddleware),
);
const store = configureStore();
export default store;

View File

@@ -1,10 +1,10 @@
import store from 'BlockBrowser/data/store';
import React from 'react';
import { Provider } from 'react-redux';
import store from './data/store';
import MainContainer from './components/Main/MainContainer';
export const ProblemBrowser = props => (
<Provider store={store}>
<MainContainer {...props} />

View File

@@ -2564,7 +2564,7 @@ class TestInstructorAPILevelsDataDump(SharedModuleStoreTestCase, LoginEnrollment
response = self.client.post(url, {'problem_location': problem_location})
res_json = json.loads(response.content.decode('utf-8'))
self.assertEqual(res_json, 'Could not find problem with this location.')
self.assertEqual(res_json, "Could not find problem with this location.")
def valid_problem_location(test): # pylint: disable=no-self-argument
"""
@@ -2838,15 +2838,16 @@ class TestInstructorAPILevelsDataDump(SharedModuleStoreTestCase, LoginEnrollment
@ddt.data(*REPORTS_DATA)
@ddt.unpack
@valid_problem_location
def test_calculate_report_csv_success(self, report_type, instructor_api_endpoint, task_api_endpoint, extra_instructor_api_kwargs):
def test_calculate_report_csv_success(
self, report_type, instructor_api_endpoint, task_api_endpoint, extra_instructor_api_kwargs
):
kwargs = {'course_id': text_type(self.course.id)}
kwargs.update(extra_instructor_api_kwargs)
url = reverse(instructor_api_endpoint, kwargs=kwargs)
success_status = u"The {report_type} report is being created.".format(report_type=report_type)
with patch(task_api_endpoint) as patched_task_api_endpoint:
patched_task_api_endpoint.return_value.task_id = "12345667-9abc-deff-ffed-cba987654321"
with patch(task_api_endpoint) as mock_task_api_endpoint:
if report_type == 'problem responses':
mock_task_api_endpoint.return_value = Mock(task_id='task-id-1138')
response = self.client.post(url, {'problem_location': ''})
self.assertContains(response, success_status)
else:

View File

@@ -973,7 +973,11 @@ class ProblemResponses(object):
# Perform the upload
problem_location = re.sub(r'[:/]', '_', problem_location)
csv_name = 'student_state_from_{}'.format(problem_location)
report_name = upload_csv_to_report_store(rows, csv_name, course_id, start_date)
current_step = {'step': 'CSV uploaded', 'report_name': report_name}
report_name, report_path = upload_csv_to_report_store(rows, csv_name, course_id, start_date)
current_step = {
'step': 'CSV uploaded',
'report_name': report_name,
'report_path': report_path,
}
return task_progress.update_task_state(extra_meta=current_step)

View File

@@ -44,8 +44,9 @@ def upload_csv_to_report_store(rows, csv_name, course_id, timestamp, config_name
)
report_store.store_rows(course_id, report_name, rows)
report_path = report_store.storage.url(report_store.path_to(course_id, report_name))
tracker_emit(csv_name)
return report_name
return report_name, report_path
def tracker_emit(report_name):

View File

@@ -313,6 +313,28 @@
}
}
}
.report-generation-status {
.msg {
display: inherit;
&.error {
color: $error-color;
}
> div {
display: inline-block;
}
a {
margin: 0 1rem;
& > div {
display: inline-block;
}
}
}
}
}
// elements - general
@@ -1509,6 +1531,10 @@
}
}
#react-problem-report {
margin: $baseline 0;
}
.block-browser {
.header {
display: flex;

View File

@@ -58,22 +58,18 @@ from openedx.core.djangolib.markup import HTML, Text
</p>
%endif
<p>
<div class="problems">
<div class="problems">
${static.renderReact(
component="ProblemBrowser",
id="react-block-listing",
id="react-problem-report",
props={
"courseId": course.id,
"excludeBlockTypes": ['html', 'video', 'discussion']
"excludeBlockTypes": ['html', 'video', 'discussion'],
"problemResponsesEndpoint": section_data['get_problem_responses_url'],
"taskStatusEndpoint": "/instructor_task_status"
}
)}
</div>
</p>
<p>
<input type="button" name="list-problem-responses-csv" value="${_("Download a CSV of problem responses")}" data-endpoint="${ section_data['get_problem_responses_url'] }" data-csv="true">
</p>
</div>
<div class="issued_certificates">
<p>${_("Click to list certificates that are issued for this course:")}</p>