feat: Load submission pending and error states (#32)
* feat: list pending and error states * fix: merge clunk * feat: fetchSubmission pending and error states * fix: lint fix * fix: fix tests * refactor: combined submission fetch * fix: make merge fixes happy * fix: linting * fix: simplify selectors and fix action bar score * fix: fix tests * Update src/data/redux/requests/selectors.js Co-authored-by: leangseu-edx <83240113+leangseu-edx@users.noreply.github.com> Co-authored-by: leangseu-edx <83240113+leangseu-edx@users.noreply.github.com>
This commit is contained in:
25
src/components/LoadingMessage.jsx
Normal file
25
src/components/LoadingMessage.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Spinner } from '@edx/paragon';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
/**
|
||||
* <LoadingMessage />
|
||||
*/
|
||||
export const LoadingMessage = ({ message }) => (
|
||||
<div className="w-100 h-100 text-center">
|
||||
<Spinner animation="border" variant="primary" />
|
||||
<h4><FormattedMessage {...message} /></h4>
|
||||
</div>
|
||||
);
|
||||
LoadingMessage.defaultProps = {
|
||||
};
|
||||
LoadingMessage.propTypes = {
|
||||
message: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
defaultMessage: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default LoadingMessage;
|
||||
63
src/containers/ListView/ListError.jsx
Normal file
63
src/containers/ListView/ListError.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Hyperlink,
|
||||
} from '@edx/paragon';
|
||||
import { Info } from '@edx/paragon/icons';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import urls from 'data/services/lms/urls';
|
||||
|
||||
import { selectors, thunkActions } from 'data/redux';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
/**
|
||||
* <ListError />
|
||||
*/
|
||||
export const ListError = ({ courseId, initializeApp }) => (
|
||||
<Alert
|
||||
variant="danger"
|
||||
icon={Info}
|
||||
actions={[
|
||||
<Button onClick={initializeApp}>Reload Submissions</Button>,
|
||||
]}
|
||||
>
|
||||
<Alert.Heading>
|
||||
<FormattedMessage {...messages.loadErrorHeading} />
|
||||
</Alert.Heading>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
{...messages.loadErrorMessage}
|
||||
values={{
|
||||
backToResponses: (
|
||||
<Hyperlink destination={urls.openResponse(courseId)}>
|
||||
<FormattedMessage {...messages.backToResponsesLowercase} />
|
||||
</Hyperlink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</Alert>
|
||||
);
|
||||
ListError.defaultProps = {
|
||||
};
|
||||
ListError.propTypes = {
|
||||
// redux
|
||||
courseId: PropTypes.string.isRequired,
|
||||
initializeApp: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
courseId: selectors.app.courseId(state),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
initializeApp: thunkActions.app.initialize,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ListError);
|
||||
67
src/containers/ListView/ListError.test.jsx
Normal file
67
src/containers/ListView/ListError.test.jsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { selectors, thunkActions } from 'data/redux';
|
||||
|
||||
import { formatMessage } from 'testUtils';
|
||||
import {
|
||||
ListError,
|
||||
mapDispatchToProps,
|
||||
mapStateToProps,
|
||||
} from './ListError';
|
||||
|
||||
jest.mock('data/redux', () => ({
|
||||
selectors: {
|
||||
app: {
|
||||
courseId: (...args) => ({ courseId: args }),
|
||||
},
|
||||
},
|
||||
thunkActions: {
|
||||
app: {
|
||||
initialize: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('data/services/lms/urls', () => ({
|
||||
openResponse: (courseId) => `api/openResponse/${courseId}`,
|
||||
}));
|
||||
|
||||
let el;
|
||||
jest.useFakeTimers('modern');
|
||||
|
||||
describe('ListError component', () => {
|
||||
describe('component', () => {
|
||||
const props = {
|
||||
courseId: 'test-course-id',
|
||||
};
|
||||
beforeEach(() => {
|
||||
props.loadSelectionForReview = jest.fn();
|
||||
props.intl = { formatMessage };
|
||||
props.initializeApp = jest.fn();
|
||||
});
|
||||
describe('render tests', () => {
|
||||
beforeEach(() => {
|
||||
el = shallow(<ListError {...props} />);
|
||||
});
|
||||
test('snapshot', () => {
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapStateToProps', () => {
|
||||
let mapped;
|
||||
const testState = { some: 'test-state' };
|
||||
beforeEach(() => {
|
||||
mapped = mapStateToProps(testState);
|
||||
});
|
||||
test('courseId loads from app.courseId', () => {
|
||||
expect(mapped.courseId).toEqual(selectors.app.courseId(testState));
|
||||
});
|
||||
});
|
||||
describe('mapDispatchToProps', () => {
|
||||
it('loads initializeApp from thunkActions.app.initialize', () => {
|
||||
expect(mapDispatchToProps.initializeApp).toEqual(thunkActions.app.initialize);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
span.pgn__icon.breadcrumb-arrow {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
};
|
||||
|
||||
@@ -17,13 +17,13 @@ import messages from './messages';
|
||||
export const ListViewBreadcrumb = ({ courseId, oraName }) => (
|
||||
<>
|
||||
<Hyperlink className="py-4" destination={urls.openResponse(courseId)}>
|
||||
<Icon icon={ArrowBack} className="mr-3" />
|
||||
<Icon src={ArrowBack} className="d-inline-block mr-3 breadcrumb-arrow" />
|
||||
<FormattedMessage {...messages.backToResponses} />
|
||||
</Hyperlink>
|
||||
<p className="h3 py-4">
|
||||
{oraName}
|
||||
<Hyperlink destination={urls.ora(courseId, locationId)} target="_blank">
|
||||
<Icon icon={Launch} />
|
||||
<Hyperlink destination={urls.ora(courseId, locationId)}>
|
||||
<Icon src={Launch} className="d-inline-block" />
|
||||
</Hyperlink>
|
||||
</p>
|
||||
</>
|
||||
|
||||
157
src/containers/ListView/SubmissionsTable.jsx
Normal file
157
src/containers/ListView/SubmissionsTable.jsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
DataTable,
|
||||
TextFilter,
|
||||
MultiSelectDropdownFilter,
|
||||
} from '@edx/paragon';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { gradingStatuses } from 'data/services/lms/constants';
|
||||
import lmsMessages from 'data/services/lms/messages';
|
||||
|
||||
import { selectors, thunkActions } from 'data/redux';
|
||||
|
||||
import StatusBadge from 'components/StatusBadge';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
/**
|
||||
* <SubmissionsTable />
|
||||
*/
|
||||
export class SubmissionsTable extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleViewAllResponsesClick = this.handleViewAllResponsesClick.bind(this);
|
||||
this.selectedBulkAction = this.selectedBulkAction.bind(this);
|
||||
}
|
||||
|
||||
get gradeStatusOptions() {
|
||||
return Object.keys(gradingStatuses).map(statusKey => ({
|
||||
name: this.translate(lmsMessages[gradingStatuses[statusKey]]),
|
||||
value: gradingStatuses[statusKey],
|
||||
}));
|
||||
}
|
||||
|
||||
formatDate = ({ value }) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
formatGrade = ({ value: score }) => (
|
||||
score === null ? '-' : `${score.pointsEarned}/${score.pointsPossible}`
|
||||
);
|
||||
|
||||
formatStatus = ({ value }) => (<StatusBadge status={value} />);
|
||||
|
||||
translate = (...args) => this.props.intl.formatMessage(...args);
|
||||
|
||||
handleViewAllResponsesClick(data) {
|
||||
const getsubmissionUUID = (row) => row.original.submissionUUID;
|
||||
const rows = data.selectedRows.length ? data.selectedRows : data.tableInstance.rows;
|
||||
this.props.loadSelectionForReview(rows.map(getsubmissionUUID));
|
||||
}
|
||||
|
||||
selectedBulkAction(selectedFlatRows) {
|
||||
return {
|
||||
buttonText: this.translate(
|
||||
messages.viewSelectedResponses,
|
||||
{ value: selectedFlatRows.length },
|
||||
),
|
||||
className: 'view-selected-responses-btn',
|
||||
handleClick: this.handleViewAllResponsesClick,
|
||||
variant: 'primary',
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.props.listData.length) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DataTable
|
||||
isFilterable
|
||||
numBreakoutFilters={2}
|
||||
defaultColumnValues={{ Filter: TextFilter }}
|
||||
isSelectable
|
||||
isSortable
|
||||
isPaginated
|
||||
itemCount={this.props.listData.length}
|
||||
initialState={{ pageSize: 10, pageIndex: 0 }}
|
||||
data={this.props.listData}
|
||||
tableActions={[
|
||||
{
|
||||
buttonText: this.translate(messages.viewAllResponses),
|
||||
handleClick: this.handleViewAllResponsesClick,
|
||||
className: 'view-all-responses-btn',
|
||||
variant: 'primary',
|
||||
},
|
||||
]}
|
||||
bulkActions={[
|
||||
this.selectedBulkAction,
|
||||
]}
|
||||
columns={[
|
||||
{
|
||||
Header: this.translate(messages.username),
|
||||
accessor: 'username',
|
||||
},
|
||||
{
|
||||
Header: this.translate(messages.learnerSubmissionDate),
|
||||
accessor: 'dateSubmitted',
|
||||
Cell: this.formatDate,
|
||||
disableFilters: true,
|
||||
},
|
||||
{
|
||||
Header: this.translate(messages.grade),
|
||||
accessor: 'score',
|
||||
Cell: this.formatGrade,
|
||||
disableFilters: true,
|
||||
},
|
||||
{
|
||||
Header: this.translate(messages.gradingStatus),
|
||||
accessor: 'gradingStatus',
|
||||
Cell: this.formatStatus,
|
||||
Filter: MultiSelectDropdownFilter,
|
||||
filter: 'includesValue',
|
||||
filterChoices: this.gradeStatusOptions,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<DataTable.TableControlBar />
|
||||
<DataTable.Table />
|
||||
<DataTable.EmptyTable content={this.translate(messages.noResultsFound)} />
|
||||
<DataTable.TableFooter />
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
}
|
||||
SubmissionsTable.defaultProps = {
|
||||
listData: [],
|
||||
};
|
||||
SubmissionsTable.propTypes = {
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
// redux
|
||||
listData: PropTypes.arrayOf(PropTypes.shape({
|
||||
username: PropTypes.string,
|
||||
dateSubmitted: PropTypes.number,
|
||||
gradingStatus: PropTypes.string,
|
||||
score: PropTypes.shape({
|
||||
pointsEarned: PropTypes.number,
|
||||
pointsPossible: PropTypes.number,
|
||||
}),
|
||||
})),
|
||||
loadSelectionForReview: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
listData: selectors.submissions.listData(state),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
loadSelectionForReview: thunkActions.grading.loadSelectionForReview,
|
||||
};
|
||||
|
||||
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(SubmissionsTable));
|
||||
246
src/containers/ListView/SubmissionsTable.test.jsx
Normal file
246
src/containers/ListView/SubmissionsTable.test.jsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import {
|
||||
DataTable,
|
||||
MultiSelectDropdownFilter,
|
||||
TextFilter,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import { selectors, thunkActions } from 'data/redux';
|
||||
import { gradingStatuses as statuses } from 'data/services/lms/constants';
|
||||
|
||||
import StatusBadge from 'components/StatusBadge';
|
||||
import { formatMessage } from 'testUtils';
|
||||
import messages from './messages';
|
||||
import {
|
||||
SubmissionsTable,
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
} from './SubmissionsTable';
|
||||
|
||||
jest.mock('data/redux', () => ({
|
||||
selectors: {
|
||||
submissions: {
|
||||
listData: (...args) => ({ listData: args }),
|
||||
},
|
||||
},
|
||||
thunkActions: {
|
||||
grading: {
|
||||
loadSelectionForReview: (...args) => ({ loadSelectionForReview: args }),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
let el;
|
||||
jest.useFakeTimers('modern');
|
||||
|
||||
describe('SubmissionsTable component', () => {
|
||||
describe('component', () => {
|
||||
const props = {
|
||||
listData: [
|
||||
{
|
||||
username: 'username-1',
|
||||
dateSubmitted: 16131215154955,
|
||||
gradingStatus: statuses.ungraded,
|
||||
score: {
|
||||
pointsEarned: 1,
|
||||
pointsPossible: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
username: 'username-2',
|
||||
dateSubmitted: 16131225154955,
|
||||
gradingStatus: statuses.graded,
|
||||
score: {
|
||||
pointsEarned: 2,
|
||||
pointsPossible: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
username: 'username-3',
|
||||
dateSubmitted: 16131215250955,
|
||||
gradingStatus: statuses.inProgress,
|
||||
score: {
|
||||
pointsEarned: 3,
|
||||
pointsPossible: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
beforeEach(() => {
|
||||
props.loadSelectionForReview = jest.fn();
|
||||
props.intl = { formatMessage };
|
||||
});
|
||||
describe('render tests', () => {
|
||||
const mockMethod = (methodName) => {
|
||||
el.instance()[methodName] = jest.fn().mockName(`this.${methodName}`);
|
||||
};
|
||||
beforeEach(() => {
|
||||
el = shallow(<SubmissionsTable {...props} />);
|
||||
});
|
||||
describe('snapshots', () => {
|
||||
beforeEach(() => {
|
||||
mockMethod('handleViewAllResponsesClick');
|
||||
mockMethod('selectedBulkAction');
|
||||
mockMethod('formatDate');
|
||||
mockMethod('formatGrade');
|
||||
mockMethod('formatStatus');
|
||||
});
|
||||
test('snapshot: empty (no list data)', () => {
|
||||
el = shallow(<SubmissionsTable {...props} listData={[]} />);
|
||||
expect(el).toMatchSnapshot();
|
||||
expect(el.isEmptyRender()).toEqual(true);
|
||||
});
|
||||
test('snapshot: happy path', () => {
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
describe('DataTable', () => {
|
||||
let table;
|
||||
let tableProps;
|
||||
beforeEach(() => {
|
||||
table = el.find(DataTable);
|
||||
tableProps = table.props();
|
||||
});
|
||||
test.each([
|
||||
'isFilterable',
|
||||
'isSelectable',
|
||||
'isSortable',
|
||||
'isPaginated',
|
||||
])('%s', key => expect(tableProps[key]).toEqual(true));
|
||||
test.each([
|
||||
['numBreakoutFilters', 2],
|
||||
['defaultColumnValues', { Filter: TextFilter }],
|
||||
['itemCount', 3],
|
||||
['initialState', { pageSize: 10, pageIndex: 0 }],
|
||||
])('%s = %p', (key, value) => expect(tableProps[key]).toEqual(value));
|
||||
test('bulkActions linked to selectedBulkAction', () => {
|
||||
expect(tableProps.bulkActions).toEqual([el.instance().selectedBulkAction]);
|
||||
});
|
||||
describe('columns', () => {
|
||||
let columns;
|
||||
beforeEach(() => {
|
||||
columns = tableProps.columns;
|
||||
});
|
||||
test('username column', () => {
|
||||
expect(columns[0]).toEqual({
|
||||
Header: messages.username.defaultMessage,
|
||||
accessor: 'username',
|
||||
});
|
||||
});
|
||||
test('submission date column', () => {
|
||||
expect(columns[1]).toEqual({
|
||||
Header: messages.learnerSubmissionDate.defaultMessage,
|
||||
accessor: 'dateSubmitted',
|
||||
Cell: el.instance().formatDate,
|
||||
disableFilters: true,
|
||||
});
|
||||
});
|
||||
test('grade column', () => {
|
||||
expect(columns[2]).toEqual({
|
||||
Header: messages.grade.defaultMessage,
|
||||
accessor: 'score',
|
||||
Cell: el.instance().formatGrade,
|
||||
disableFilters: true,
|
||||
});
|
||||
});
|
||||
test('grading status column', () => {
|
||||
expect(columns[3]).toEqual({
|
||||
Header: messages.gradingStatus.defaultMessage,
|
||||
accessor: 'gradingStatus',
|
||||
Cell: el.instance().formatStatus,
|
||||
Filter: MultiSelectDropdownFilter,
|
||||
filter: 'includesValue',
|
||||
filterChoices: el.instance().gradeStatusOptions,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('behavior', () => {
|
||||
describe('formatDate method', () => {
|
||||
it('returns the date in locale time string', () => {
|
||||
const fakeDate = 16131215154955;
|
||||
const fakeDateString = 'test-date-string';
|
||||
const mock = jest.spyOn(Date.prototype, 'toLocaleString').mockReturnValue(fakeDateString);
|
||||
expect(el.instance().formatDate({ value: fakeDate })).toEqual(fakeDateString);
|
||||
mock.mockRestore();
|
||||
});
|
||||
});
|
||||
describe('formatGrade method', () => {
|
||||
it('returns "-" if grade is null', () => {
|
||||
expect(el.instance().formatGrade({ value: null })).toEqual('-');
|
||||
});
|
||||
it('returns <pointsEarned>/<pointsPossible> if grade exists', () => {
|
||||
expect(
|
||||
el.instance().formatGrade({ value: { pointsEarned: 1, pointsPossible: 10 } }),
|
||||
).toEqual('1/10');
|
||||
});
|
||||
});
|
||||
describe('formatStatus method', () => {
|
||||
it('returns a StatusBadge with the given status', () => {
|
||||
const status = 'graded';
|
||||
expect(el.instance().formatStatus({ value: 'graded' })).toEqual(
|
||||
<StatusBadge status={status} />,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('handleViewAllResponsesClick', () => {
|
||||
it('calls loadSelectionForReview with submissionUUID from all rows if there are no selectedRows', () => {
|
||||
const data = {
|
||||
selectedRows: [
|
||||
],
|
||||
tableInstance: {
|
||||
rows: [
|
||||
{ original: { submissionUUID: '123' } },
|
||||
{ original: { submissionUUID: '456' } },
|
||||
{ original: { submissionUUID: '789' } },
|
||||
],
|
||||
},
|
||||
};
|
||||
el.instance().handleViewAllResponsesClick(data);
|
||||
expect(el.instance().props.loadSelectionForReview).toHaveBeenCalledWith(['123', '456', '789']);
|
||||
});
|
||||
it('calls loadSelectionForReview with submissionUUID from selected rows if there are any', () => {
|
||||
const data = {
|
||||
selectedRows: [
|
||||
{ original: { submissionUUID: '123' } },
|
||||
{ original: { submissionUUID: '456' } },
|
||||
{ original: { submissionUUID: '789' } },
|
||||
],
|
||||
};
|
||||
el.instance().handleViewAllResponsesClick(data);
|
||||
expect(
|
||||
el.instance().props.loadSelectionForReview,
|
||||
).toHaveBeenCalledWith(['123', '456', '789']);
|
||||
});
|
||||
});
|
||||
describe('selectedBulkAction', () => {
|
||||
it('includes selection length and triggers handleViewAllResponsesClick', () => {
|
||||
const rows = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
const action = el.instance().selectedBulkAction(rows);
|
||||
expect(action.buttonText).toEqual(expect.stringContaining(rows.length.toString()));
|
||||
expect(action.handleClick).toEqual(el.instance().handleViewAllResponsesClick);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapStateToProps', () => {
|
||||
let mapped;
|
||||
const testState = { some: 'test-state' };
|
||||
beforeEach(() => {
|
||||
mapped = mapStateToProps(testState);
|
||||
});
|
||||
test('listData loads from submissions.listData', () => {
|
||||
expect(mapped.listData).toEqual(selectors.submissions.listData(testState));
|
||||
});
|
||||
});
|
||||
describe('mapDispatchToProps', () => {
|
||||
it('loads loadSelectionForReview from thunkActions.grading.loadSelectionForReview', () => {
|
||||
expect(
|
||||
mapDispatchToProps.loadSelectionForReview,
|
||||
).toEqual(thunkActions.grading.loadSelectionForReview);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ListError component component render tests snapshot 1`] = `
|
||||
<Alert
|
||||
actions={
|
||||
Array [
|
||||
<Button
|
||||
onClick={[MockFunction]}
|
||||
>
|
||||
Reload Submissions
|
||||
</Button>,
|
||||
]
|
||||
}
|
||||
variant="danger"
|
||||
>
|
||||
<Alert.Heading>
|
||||
<FormattedMessage
|
||||
defaultMessage="Error loading submissions"
|
||||
description="Initialization failure alert header"
|
||||
id="ora-grading.ListView.loadErrorHeading"
|
||||
/>
|
||||
</Alert.Heading>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
defaultMessage="An error occurred while loading the submissions for this response. Try reloading the page or going {backToResponses}."
|
||||
description="Initialization failure alert message line 2"
|
||||
id="ora-grading.ListView.loadErrorMessage1"
|
||||
values={
|
||||
Object {
|
||||
"backToResponses": <Hyperlink
|
||||
destination="api/openResponse/test-course-id"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="back to all Open Responses"
|
||||
description="lowercase string for link to list of all open responses in lms"
|
||||
id="ora-grading.ListView.backToResponsesLowercase"
|
||||
/>
|
||||
</Hyperlink>,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
</Alert>
|
||||
`;
|
||||
@@ -7,8 +7,8 @@ exports[`ListViewBreadcrumb component component snapshot: empty (no list data) 1
|
||||
destination="openResponseUrl(test-course-id)"
|
||||
>
|
||||
<Icon
|
||||
className="mr-3"
|
||||
icon={[MockFunction icons.ArrowBack]}
|
||||
className="d-inline-block mr-3 breadcrumb-arrow"
|
||||
src={[MockFunction icons.ArrowBack]}
|
||||
/>
|
||||
<FormattedMessage
|
||||
defaultMessage="Back to all open responses"
|
||||
@@ -22,10 +22,10 @@ exports[`ListViewBreadcrumb component component snapshot: empty (no list data) 1
|
||||
fake-ora-name
|
||||
<Hyperlink
|
||||
destination="oraUrl(test-course-id, fake-location-id)"
|
||||
target="_blank"
|
||||
>
|
||||
<Icon
|
||||
icon={[MockFunction icons.Launch]}
|
||||
className="d-inline-block"
|
||||
src={[MockFunction icons.Launch]}
|
||||
/>
|
||||
</Hyperlink>
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`SubmissionsTable component component render tests snapshots snapshot: empty (no list data) 1`] = `""`;
|
||||
|
||||
exports[`SubmissionsTable component component render tests snapshots snapshot: happy path 1`] = `
|
||||
<DataTable
|
||||
bulkActions={
|
||||
Array [
|
||||
[MockFunction this.selectedBulkAction],
|
||||
]
|
||||
}
|
||||
columns={
|
||||
Array [
|
||||
Object {
|
||||
"Header": "Username",
|
||||
"accessor": "username",
|
||||
},
|
||||
Object {
|
||||
"Cell": [MockFunction this.formatDate],
|
||||
"Header": "Learner submission date",
|
||||
"accessor": "dateSubmitted",
|
||||
"disableFilters": true,
|
||||
},
|
||||
Object {
|
||||
"Cell": [MockFunction this.formatGrade],
|
||||
"Header": "Grade",
|
||||
"accessor": "score",
|
||||
"disableFilters": true,
|
||||
},
|
||||
Object {
|
||||
"Cell": [MockFunction this.formatStatus],
|
||||
"Filter": "MultiSelectDropdownFilter",
|
||||
"Header": "Grading status",
|
||||
"accessor": "gradingStatus",
|
||||
"filter": "includesValue",
|
||||
"filterChoices": Array [
|
||||
Object {
|
||||
"name": "Ungraded",
|
||||
"value": "ungraded",
|
||||
},
|
||||
Object {
|
||||
"name": "Grading Completed",
|
||||
"value": "graded",
|
||||
},
|
||||
Object {
|
||||
"name": "Currently being graded by someone else",
|
||||
"value": "locked",
|
||||
},
|
||||
Object {
|
||||
"name": "You are currently grading this response",
|
||||
"value": "in-progress",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
data={
|
||||
Array [
|
||||
Object {
|
||||
"dateSubmitted": 16131215154955,
|
||||
"gradingStatus": "ungraded",
|
||||
"score": Object {
|
||||
"pointsEarned": 1,
|
||||
"pointsPossible": 10,
|
||||
},
|
||||
"username": "username-1",
|
||||
},
|
||||
Object {
|
||||
"dateSubmitted": 16131225154955,
|
||||
"gradingStatus": "graded",
|
||||
"score": Object {
|
||||
"pointsEarned": 2,
|
||||
"pointsPossible": 10,
|
||||
},
|
||||
"username": "username-2",
|
||||
},
|
||||
Object {
|
||||
"dateSubmitted": 16131215250955,
|
||||
"gradingStatus": "in-progress",
|
||||
"score": Object {
|
||||
"pointsEarned": 3,
|
||||
"pointsPossible": 10,
|
||||
},
|
||||
"username": "username-3",
|
||||
},
|
||||
]
|
||||
}
|
||||
defaultColumnValues={
|
||||
Object {
|
||||
"Filter": "TextFilter",
|
||||
}
|
||||
}
|
||||
initialState={
|
||||
Object {
|
||||
"pageIndex": 0,
|
||||
"pageSize": 10,
|
||||
}
|
||||
}
|
||||
isFilterable={true}
|
||||
isPaginated={true}
|
||||
isSelectable={true}
|
||||
isSortable={true}
|
||||
itemCount={3}
|
||||
numBreakoutFilters={2}
|
||||
tableActions={
|
||||
Array [
|
||||
Object {
|
||||
"buttonText": "View all responses",
|
||||
"className": "view-all-responses-btn",
|
||||
"handleClick": [MockFunction this.handleViewAllResponsesClick],
|
||||
"variant": "primary",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<DataTable.TableControlBar />
|
||||
<DataTable.Table />
|
||||
<DataTable.EmptyTable
|
||||
content="No results found"
|
||||
/>
|
||||
<DataTable.TableFooter />
|
||||
</DataTable>
|
||||
`;
|
||||
@@ -1,129 +1,45 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ListView component component render tests snapshots snapshot: empty (no list data) 1`] = `""`;
|
||||
|
||||
exports[`ListView component component render tests snapshots snapshot: happy path 1`] = `
|
||||
exports[`ListView component component render tests snapshots snapshot: error 1`] = `
|
||||
<Container
|
||||
className="py-4"
|
||||
>
|
||||
<ListViewBreadcrumb />
|
||||
<DataTable
|
||||
bulkActions={
|
||||
Array [
|
||||
[MockFunction this.selectedBulkAction],
|
||||
]
|
||||
}
|
||||
columns={
|
||||
Array [
|
||||
Object {
|
||||
"Header": "Username",
|
||||
"accessor": "username",
|
||||
},
|
||||
Object {
|
||||
"Cell": [MockFunction this.formatDate],
|
||||
"Header": "Learner submission date",
|
||||
"accessor": "dateSubmitted",
|
||||
"disableFilters": true,
|
||||
},
|
||||
Object {
|
||||
"Cell": [MockFunction this.formatGrade],
|
||||
"Header": "Grade",
|
||||
"accessor": "score",
|
||||
"disableFilters": true,
|
||||
},
|
||||
Object {
|
||||
"Cell": [MockFunction this.formatStatus],
|
||||
"Filter": "MultiSelectDropdownFilter",
|
||||
"Header": "Grading status",
|
||||
"accessor": "gradingStatus",
|
||||
"filter": "includesValue",
|
||||
"filterChoices": Array [
|
||||
Object {
|
||||
"name": "Ungraded",
|
||||
"value": "ungraded",
|
||||
},
|
||||
Object {
|
||||
"name": "Grading Completed",
|
||||
"value": "graded",
|
||||
},
|
||||
Object {
|
||||
"name": "Currently being graded by someone else",
|
||||
"value": "locked",
|
||||
},
|
||||
Object {
|
||||
"name": "You are currently grading this response",
|
||||
"value": "in-progress",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
data={
|
||||
Array [
|
||||
Object {
|
||||
"dateSubmitted": 16131215154955,
|
||||
"grade": Object {
|
||||
"pointsEarned": 1,
|
||||
"pointsPossible": 10,
|
||||
},
|
||||
"gradingStatus": "ungraded",
|
||||
"username": "username-1",
|
||||
},
|
||||
Object {
|
||||
"dateSubmitted": 16131225154955,
|
||||
"grade": Object {
|
||||
"pointsEarned": 2,
|
||||
"pointsPossible": 10,
|
||||
},
|
||||
"gradingStatus": "graded",
|
||||
"username": "username-2",
|
||||
},
|
||||
Object {
|
||||
"dateSubmitted": 16131215250955,
|
||||
"grade": Object {
|
||||
"pointsEarned": 3,
|
||||
"pointsPossible": 10,
|
||||
},
|
||||
"gradingStatus": "in-progress",
|
||||
"username": "username-3",
|
||||
},
|
||||
]
|
||||
}
|
||||
defaultColumnValues={
|
||||
Object {
|
||||
"Filter": "TextFilter",
|
||||
}
|
||||
}
|
||||
initialState={
|
||||
Object {
|
||||
"pageIndex": 0,
|
||||
"pageSize": 10,
|
||||
}
|
||||
}
|
||||
isFilterable={true}
|
||||
isPaginated={true}
|
||||
isSelectable={true}
|
||||
isSortable={true}
|
||||
itemCount={3}
|
||||
numBreakoutFilters={2}
|
||||
tableActions={
|
||||
Array [
|
||||
Object {
|
||||
"buttonText": "View all responses",
|
||||
"className": "view-all-responses-btn",
|
||||
"handleClick": [MockFunction this.handleViewAllResponsesClick],
|
||||
"variant": "primary",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<DataTable.TableControlBar />
|
||||
<DataTable.Table />
|
||||
<DataTable.EmptyTable
|
||||
content="No results found"
|
||||
/>
|
||||
<DataTable.TableFooter />
|
||||
</DataTable>
|
||||
<ListError />
|
||||
<ReviewModal />
|
||||
</Container>
|
||||
`;
|
||||
|
||||
exports[`ListView component component render tests snapshots snapshot: loaded 1`] = `
|
||||
<Container
|
||||
className="py-4"
|
||||
>
|
||||
<ListViewBreadcrumb />
|
||||
<SubmissionsTable />
|
||||
<ReviewModal />
|
||||
</Container>
|
||||
`;
|
||||
|
||||
exports[`ListView component component render tests snapshots snapshot: loading 1`] = `
|
||||
<Container
|
||||
className="py-4"
|
||||
>
|
||||
<ListViewBreadcrumb />
|
||||
<div
|
||||
className="w-100 h-100 text-center"
|
||||
>
|
||||
<Spinner
|
||||
animation="border"
|
||||
variant="primary"
|
||||
/>
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
defaultMessage="Loading responses"
|
||||
description="loading text for submission response list"
|
||||
id="ora-grading.ListView.loadingResponses"
|
||||
/>
|
||||
</h4>
|
||||
</div>
|
||||
<ReviewModal />
|
||||
</Container>
|
||||
`;
|
||||
|
||||
@@ -3,22 +3,19 @@ import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
DataTable,
|
||||
TextFilter,
|
||||
MultiSelectDropdownFilter,
|
||||
Container,
|
||||
Spinner,
|
||||
} from '@edx/paragon';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { gradingStatuses } from 'data/services/lms/constants';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { selectors, thunkActions } from 'data/redux';
|
||||
import lmsMessages from 'data/services/lms/messages';
|
||||
import { RequestKeys } from 'data/constants/requests';
|
||||
|
||||
import StatusBadge from 'components/StatusBadge';
|
||||
import ReviewModal from 'containers/ReviewModal';
|
||||
|
||||
import ListError from './ListError';
|
||||
import ListViewBreadcrumb from './ListViewBreadcrumb';
|
||||
import SubmissionsTable from './SubmissionsTable';
|
||||
import messages from './messages';
|
||||
import './ListView.scss';
|
||||
|
||||
@@ -29,142 +26,46 @@ export class ListView extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.props.initializeApp();
|
||||
this.handleViewAllResponsesClick = this.handleViewAllResponsesClick.bind(this);
|
||||
this.selectedBulkAction = this.selectedBulkAction.bind(this);
|
||||
}
|
||||
|
||||
get gradeStatusOptions() {
|
||||
return Object.keys(gradingStatuses).map(statusKey => ({
|
||||
name: this.translate(lmsMessages[gradingStatuses[statusKey]]),
|
||||
value: gradingStatuses[statusKey],
|
||||
}));
|
||||
}
|
||||
|
||||
formatDate = ({ value }) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
formatGrade = ({ value: grade }) => (
|
||||
grade === null ? '-' : `${grade.pointsEarned}/${grade.pointsPossible}`
|
||||
);
|
||||
|
||||
formatStatus = ({ value }) => (<StatusBadge status={value} />);
|
||||
|
||||
translate = (...args) => this.props.intl.formatMessage(...args);
|
||||
|
||||
handleViewAllResponsesClick(data) {
|
||||
const getsubmissionUUID = (row) => row.original.submissionUUID;
|
||||
const rows = data.selectedRows.length ? data.selectedRows : data.tableInstance.rows;
|
||||
this.props.loadSelectionForReview(rows.map(getsubmissionUUID));
|
||||
}
|
||||
|
||||
selectedBulkAction(selectedFlatRows) {
|
||||
return {
|
||||
buttonText: this.translate(
|
||||
messages.viewSelectedResponses,
|
||||
{ value: selectedFlatRows.length },
|
||||
),
|
||||
className: 'view-selected-responses-btn',
|
||||
handleClick: this.handleViewAllResponsesClick,
|
||||
variant: 'primary',
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
// hide if submissions are not loaded.
|
||||
if (this.props.listData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { isLoaded, hasError } = this.props;
|
||||
return (
|
||||
<Container className="py-4">
|
||||
<ListViewBreadcrumb />
|
||||
<DataTable
|
||||
isFilterable
|
||||
numBreakoutFilters={2}
|
||||
defaultColumnValues={{ Filter: TextFilter }}
|
||||
isSelectable
|
||||
isSortable
|
||||
isPaginated
|
||||
itemCount={this.props.listData.length}
|
||||
initialState={{ pageSize: 10, pageIndex: 0 }}
|
||||
data={this.props.listData}
|
||||
tableActions={[
|
||||
{
|
||||
buttonText: this.translate(messages.viewAllResponses),
|
||||
handleClick: this.handleViewAllResponsesClick,
|
||||
className: 'view-all-responses-btn',
|
||||
variant: 'primary',
|
||||
},
|
||||
]}
|
||||
bulkActions={[
|
||||
this.selectedBulkAction,
|
||||
]}
|
||||
columns={[
|
||||
{
|
||||
Header: this.translate(messages.username),
|
||||
accessor: 'username',
|
||||
},
|
||||
{
|
||||
Header: this.translate(messages.learnerSubmissionDate),
|
||||
accessor: 'dateSubmitted',
|
||||
Cell: this.formatDate,
|
||||
disableFilters: true,
|
||||
},
|
||||
{
|
||||
Header: this.translate(messages.grade),
|
||||
accessor: 'score',
|
||||
Cell: this.formatGrade,
|
||||
disableFilters: true,
|
||||
},
|
||||
{
|
||||
Header: this.translate(messages.gradingStatus),
|
||||
accessor: 'gradingStatus',
|
||||
Cell: this.formatStatus,
|
||||
Filter: MultiSelectDropdownFilter,
|
||||
filter: 'includesValue',
|
||||
filterChoices: this.gradeStatusOptions,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<DataTable.TableControlBar />
|
||||
<DataTable.Table />
|
||||
<DataTable.EmptyTable content={this.translate(messages.noResultsFound)} />
|
||||
<DataTable.TableFooter />
|
||||
</DataTable>
|
||||
{ isLoaded && <SubmissionsTable /> }
|
||||
{ hasError && <ListError /> }
|
||||
{ (!isLoaded && !hasError) && (
|
||||
<div className="w-100 h-100 text-center">
|
||||
<Spinner animation="border" variant="primary" />
|
||||
<h4><FormattedMessage {...messages.loadingResponses} /></h4>
|
||||
</div>
|
||||
)}
|
||||
<ReviewModal />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
ListView.defaultProps = {
|
||||
listData: [],
|
||||
};
|
||||
ListView.propTypes = {
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
// redux
|
||||
courseId: PropTypes.string.isRequired,
|
||||
initializeApp: PropTypes.func.isRequired,
|
||||
listData: PropTypes.arrayOf(PropTypes.shape({
|
||||
username: PropTypes.string,
|
||||
dateSubmitted: PropTypes.number,
|
||||
gradingStatus: PropTypes.string,
|
||||
score: PropTypes.shape({
|
||||
pointsEarned: PropTypes.number,
|
||||
pointsPossible: PropTypes.number,
|
||||
}),
|
||||
})),
|
||||
loadSelectionForReview: PropTypes.func.isRequired,
|
||||
isLoaded: PropTypes.bool.isRequired,
|
||||
isPending: PropTypes.bool.isRequired,
|
||||
hasError: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
listData: selectors.submissions.listData(state),
|
||||
courseId: selectors.app.courseId(state),
|
||||
isLoaded: selectors.requests.isCompleted(state, { requestKey: RequestKeys.initialize }),
|
||||
isPending: selectors.requests.isPending(state, { requestKey: RequestKeys.initialize }),
|
||||
hasError: selectors.requests.isFailed(state, { requestKey: RequestKeys.initialize }),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
initializeApp: thunkActions.app.initialize,
|
||||
loadSelectionForReview: thunkActions.grading.loadSelectionForReview,
|
||||
};
|
||||
|
||||
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ListView));
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ListView);
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import {
|
||||
DataTable,
|
||||
MultiSelectDropdownFilter,
|
||||
TextFilter,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import { selectors, thunkActions } from 'data/redux';
|
||||
import { gradingStatuses as statuses } from 'data/services/lms/constants';
|
||||
import { RequestKeys } from 'data/constants/requests';
|
||||
|
||||
import StatusBadge from 'components/StatusBadge';
|
||||
import { formatMessage } from 'testUtils';
|
||||
import messages from './messages';
|
||||
import {
|
||||
ListView,
|
||||
mapStateToProps,
|
||||
@@ -22,9 +14,19 @@ import {
|
||||
jest.mock('components/StatusBadge', () => 'StatusBadge');
|
||||
jest.mock('containers/ReviewModal', () => 'ReviewModal');
|
||||
jest.mock('./ListViewBreadcrumb', () => 'ListViewBreadcrumb');
|
||||
jest.mock('./ListError', () => 'ListError');
|
||||
jest.mock('./SubmissionsTable', () => 'SubmissionsTable');
|
||||
|
||||
jest.mock('data/redux', () => ({
|
||||
selectors: {
|
||||
app: {
|
||||
courseId: (...args) => ({ courseId: args }),
|
||||
},
|
||||
requests: {
|
||||
isCompleted: (...args) => ({ isCompleted: args }),
|
||||
isPending: (...args) => ({ isPending: args }),
|
||||
isFailed: (...args) => ({ isFailed: args }),
|
||||
},
|
||||
submissions: {
|
||||
listData: (...args) => ({ listData: args }),
|
||||
},
|
||||
@@ -33,225 +35,77 @@ jest.mock('data/redux', () => ({
|
||||
app: {
|
||||
initialize: (...args) => ({ initialize: args }),
|
||||
},
|
||||
grading: {
|
||||
loadSelectionForReview: (...args) => ({ loadSelectionForReview: args }),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@edx/paragon', () => ({
|
||||
Container: 'Container',
|
||||
Spinner: 'Spinner',
|
||||
}));
|
||||
|
||||
let el;
|
||||
jest.useFakeTimers('modern');
|
||||
|
||||
describe('ListView component', () => {
|
||||
describe('component', () => {
|
||||
const props = {
|
||||
listData: [
|
||||
{
|
||||
username: 'username-1',
|
||||
dateSubmitted: 16131215154955,
|
||||
gradingStatus: statuses.ungraded,
|
||||
grade: {
|
||||
pointsEarned: 1,
|
||||
pointsPossible: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
username: 'username-2',
|
||||
dateSubmitted: 16131225154955,
|
||||
gradingStatus: statuses.graded,
|
||||
grade: {
|
||||
pointsEarned: 2,
|
||||
pointsPossible: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
username: 'username-3',
|
||||
dateSubmitted: 16131215250955,
|
||||
gradingStatus: statuses.inProgress,
|
||||
grade: {
|
||||
pointsEarned: 3,
|
||||
pointsPossible: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
courseId: 'test-course-id',
|
||||
isLoaded: false,
|
||||
isPending: false,
|
||||
hasError: false,
|
||||
};
|
||||
beforeEach(() => {
|
||||
props.initializeApp = jest.fn();
|
||||
props.loadSelectionForReview = jest.fn();
|
||||
props.intl = { formatMessage };
|
||||
});
|
||||
describe('render tests', () => {
|
||||
const mockMethod = (methodName) => {
|
||||
el.instance()[methodName] = jest.fn().mockName(`this.${methodName}`);
|
||||
};
|
||||
beforeEach(() => {
|
||||
el = shallow(<ListView {...props} />);
|
||||
});
|
||||
describe('snapshots', () => {
|
||||
beforeEach(() => {
|
||||
mockMethod('handleViewAllResponsesClick');
|
||||
mockMethod('selectedBulkAction');
|
||||
mockMethod('formatDate');
|
||||
mockMethod('formatGrade');
|
||||
mockMethod('formatStatus');
|
||||
});
|
||||
test('snapshot: empty (no list data)', () => {
|
||||
el = shallow(<ListView {...props} listData={[]} />);
|
||||
test('snapshot: loading', () => {
|
||||
expect(el).toMatchSnapshot();
|
||||
expect(el.isEmptyRender()).toEqual(true);
|
||||
});
|
||||
test('snapshot: happy path', () => {
|
||||
test('snapshot: loaded', () => {
|
||||
el.setProps({ isLoaded: true });
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
describe('DataTable', () => {
|
||||
let table;
|
||||
let tableProps;
|
||||
beforeEach(() => {
|
||||
table = el.find(DataTable);
|
||||
tableProps = table.props();
|
||||
});
|
||||
test.each([
|
||||
'isFilterable',
|
||||
'isSelectable',
|
||||
'isSortable',
|
||||
'isPaginated',
|
||||
])('%s', key => expect(tableProps[key]).toEqual(true));
|
||||
test.each([
|
||||
['numBreakoutFilters', 2],
|
||||
['defaultColumnValues', { Filter: TextFilter }],
|
||||
['itemCount', 3],
|
||||
['initialState', { pageSize: 10, pageIndex: 0 }],
|
||||
])('%s = %p', (key, value) => expect(tableProps[key]).toEqual(value));
|
||||
test('bulkActions linked to selectedBulkAction', () => {
|
||||
expect(tableProps.bulkActions).toEqual([el.instance().selectedBulkAction]);
|
||||
});
|
||||
describe('columns', () => {
|
||||
let columns;
|
||||
beforeEach(() => {
|
||||
columns = tableProps.columns;
|
||||
});
|
||||
test('username column', () => {
|
||||
expect(columns[0]).toEqual({
|
||||
Header: messages.username.defaultMessage,
|
||||
accessor: 'username',
|
||||
});
|
||||
});
|
||||
test('submission date column', () => {
|
||||
expect(columns[1]).toEqual({
|
||||
Header: messages.learnerSubmissionDate.defaultMessage,
|
||||
accessor: 'dateSubmitted',
|
||||
Cell: el.instance().formatDate,
|
||||
disableFilters: true,
|
||||
});
|
||||
});
|
||||
test('grade column', () => {
|
||||
expect(columns[2]).toEqual({
|
||||
Header: messages.grade.defaultMessage,
|
||||
accessor: 'score',
|
||||
Cell: el.instance().formatGrade,
|
||||
disableFilters: true,
|
||||
});
|
||||
});
|
||||
test('grading status column', () => {
|
||||
expect(columns[3]).toEqual({
|
||||
Header: messages.gradingStatus.defaultMessage,
|
||||
accessor: 'gradingStatus',
|
||||
Cell: el.instance().formatStatus,
|
||||
Filter: MultiSelectDropdownFilter,
|
||||
filter: 'includesValue',
|
||||
filterChoices: el.instance().gradeStatusOptions,
|
||||
});
|
||||
});
|
||||
test('snapshot: error', () => {
|
||||
el.setProps({ hasError: true });
|
||||
expect(el.instance().render()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('behavior', () => {
|
||||
describe('formatDate method', () => {
|
||||
it('returns the date in locale time string', () => {
|
||||
const fakeDate = 16131215154955;
|
||||
const fakeDateString = 'test-date-string';
|
||||
const mock = jest.spyOn(Date.prototype, 'toLocaleString').mockReturnValue(fakeDateString);
|
||||
expect(el.instance().formatDate({ value: fakeDate })).toEqual(fakeDateString);
|
||||
mock.mockRestore();
|
||||
});
|
||||
});
|
||||
describe('formatGrade method', () => {
|
||||
it('returns "-" if grade is null', () => {
|
||||
expect(el.instance().formatGrade({ value: null })).toEqual('-');
|
||||
});
|
||||
it('returns <pointsEarned>/<pointsPossible> if grade exists', () => {
|
||||
expect(
|
||||
el.instance().formatGrade({ value: { pointsEarned: 1, pointsPossible: 10 } }),
|
||||
).toEqual('1/10');
|
||||
});
|
||||
});
|
||||
describe('formatStatus method', () => {
|
||||
it('returns a StatusBadge with the given status', () => {
|
||||
const status = 'graded';
|
||||
expect(el.instance().formatStatus({ value: 'graded' })).toEqual(
|
||||
<StatusBadge status={status} />,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('handleViewAllResponsesClick', () => {
|
||||
it('calls loadSelectionForReview with submissionUUID from all rows if there are no selectedRows', () => {
|
||||
const data = {
|
||||
selectedRows: [
|
||||
],
|
||||
tableInstance: {
|
||||
rows: [
|
||||
{ original: { submissionUUID: '123' } },
|
||||
{ original: { submissionUUID: '456' } },
|
||||
{ original: { submissionUUID: '789' } },
|
||||
],
|
||||
},
|
||||
};
|
||||
el.instance().handleViewAllResponsesClick(data);
|
||||
expect(el.instance().props.loadSelectionForReview).toHaveBeenCalledWith(['123', '456', '789']);
|
||||
});
|
||||
it('calls loadSelectionForReview with submissionUUID from selected rows if there are any', () => {
|
||||
const data = {
|
||||
selectedRows: [
|
||||
{ original: { submissionUUID: '123' } },
|
||||
{ original: { submissionUUID: '456' } },
|
||||
{ original: { submissionUUID: '789' } },
|
||||
],
|
||||
};
|
||||
el.instance().handleViewAllResponsesClick(data);
|
||||
expect(
|
||||
el.instance().props.loadSelectionForReview,
|
||||
).toHaveBeenCalledWith(['123', '456', '789']);
|
||||
});
|
||||
});
|
||||
describe('selectedBulkAction', () => {
|
||||
it('includes selection length and triggers handleViewAllResponsesClick', () => {
|
||||
const rows = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
const action = el.instance().selectedBulkAction(rows);
|
||||
expect(action.buttonText).toEqual(expect.stringContaining(rows.length.toString()));
|
||||
expect(action.handleClick).toEqual(el.instance().handleViewAllResponsesClick);
|
||||
});
|
||||
it('calls initializeApp on load', () => {
|
||||
el = shallow(<ListView {...props} />);
|
||||
expect(props.initializeApp).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapStateToProps', () => {
|
||||
let mapped;
|
||||
const testState = { some: 'test-state' };
|
||||
const requestKey = RequestKeys.initialize;
|
||||
beforeEach(() => {
|
||||
mapped = mapStateToProps(testState);
|
||||
});
|
||||
test('listData loads from submissions.listData', () => {
|
||||
expect(mapped.listData).toEqual(selectors.submissions.listData(testState));
|
||||
test('courseId loads from app.courseId', () => {
|
||||
expect(mapped.courseId).toEqual(selectors.app.courseId(testState));
|
||||
});
|
||||
test('isLoaded loads from requests.isCompleted', () => {
|
||||
expect(mapped.isLoaded).toEqual(selectors.requests.isCompleted(testState, { requestKey }));
|
||||
});
|
||||
test('isPending loads from requests.isPending', () => {
|
||||
expect(mapped.isPending).toEqual(selectors.requests.isPending(testState, { requestKey }));
|
||||
});
|
||||
test('hasError loads from requests.isFailed', () => {
|
||||
expect(mapped.hasError).toEqual(selectors.requests.isFailed(testState, { requestKey }));
|
||||
});
|
||||
});
|
||||
describe('mapDispatchToProps', () => {
|
||||
it('loads initializeApp from thunkActions.app.initialize', () => {
|
||||
expect(mapDispatchToProps.initializeApp).toEqual(thunkActions.app.initialize);
|
||||
});
|
||||
it('loads loadSelectionForReview from thunkActions.grading.loadSelectionForReview', () => {
|
||||
expect(
|
||||
mapDispatchToProps.loadSelectionForReview,
|
||||
).toEqual(thunkActions.grading.loadSelectionForReview);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,6 +51,31 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Grading status',
|
||||
description: 'Grading status table column header for submission list view',
|
||||
},
|
||||
loadErrorHeading: {
|
||||
id: 'ora-grading.ListView.loadErrorHeading',
|
||||
defaultMessage: 'Error loading submissions',
|
||||
description: 'Initialization failure alert header',
|
||||
},
|
||||
loadErrorMessage: {
|
||||
id: 'ora-grading.ListView.loadErrorMessage1',
|
||||
defaultMessage: 'An error occurred while loading the submissions for this response. Try reloading the page or going {backToResponses}.',
|
||||
description: 'Initialization failure alert message line 2',
|
||||
},
|
||||
backToResponsesLowercase: {
|
||||
id: 'ora-grading.ListView.backToResponsesLowercase',
|
||||
defaultMessage: 'back to all Open Responses',
|
||||
description: 'lowercase string for link to list of all open responses in lms',
|
||||
},
|
||||
reloadSubmissions: {
|
||||
id: 'ora-grading.ListView.reloadSubmissions',
|
||||
defaultMessage: 'Reload submissions',
|
||||
description: 'Reload button text in case of network failure',
|
||||
},
|
||||
loadingResponses: {
|
||||
id: 'ora-grading.ListView.loadingResponses',
|
||||
defaultMessage: 'Loading responses',
|
||||
description: 'loading text for submission response list',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -53,7 +53,49 @@ exports[`ReviewActions component component snapshot: do not show rubric 1`] = `
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`ReviewActions component component snapshot: show rubric, no points 1`] = `
|
||||
exports[`ReviewActions component component snapshot: loading 1`] = `
|
||||
<div>
|
||||
<ActionRow
|
||||
className="review-actions"
|
||||
>
|
||||
<span
|
||||
className="review-actions-username"
|
||||
>
|
||||
<span
|
||||
className="lead"
|
||||
>
|
||||
test-username
|
||||
</span>
|
||||
<StatusBadge
|
||||
className="review-actions-status mr-3"
|
||||
status="grading-status"
|
||||
/>
|
||||
<span
|
||||
className="small"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Score: {pointsEarned}/{pointsPossible}"
|
||||
description="Review pane action bar score display"
|
||||
id="ora-grading.ReviewActions.pointsDisplay"
|
||||
values={
|
||||
Object {
|
||||
"pointsEarned": 3,
|
||||
"pointsPossible": 10,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<div
|
||||
className="review-actions-group"
|
||||
>
|
||||
<SubmissionNavigation />
|
||||
</div>
|
||||
</ActionRow>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`ReviewActions component component snapshot: show rubric, no score 1`] = `
|
||||
<div>
|
||||
<ActionRow
|
||||
className="review-actions"
|
||||
|
||||
@@ -20,12 +20,13 @@ export const SubmissionNavigation = ({
|
||||
loadNext,
|
||||
activeIndex,
|
||||
selectionLength,
|
||||
allowNavigation,
|
||||
}) => (
|
||||
<span className="submission-navigation">
|
||||
<IconButton
|
||||
className="ml-1"
|
||||
size="inline"
|
||||
disabled={!hasPrevSubmission}
|
||||
disabled={!hasPrevSubmission || !allowNavigation}
|
||||
alt={intl.formatMessage(messages.loadPrevious)}
|
||||
src={ChevronLeft}
|
||||
iconAs={Icon}
|
||||
@@ -40,7 +41,7 @@ export const SubmissionNavigation = ({
|
||||
<IconButton
|
||||
className="ml-1"
|
||||
size="inline"
|
||||
disabled={!hasNextSubmission}
|
||||
disabled={!hasNextSubmission || !allowNavigation}
|
||||
alt={intl.formatMessage(messages.loadNext)}
|
||||
src={ChevronRight}
|
||||
iconAs={Icon}
|
||||
@@ -51,11 +52,13 @@ export const SubmissionNavigation = ({
|
||||
SubmissionNavigation.defaultProps = {
|
||||
hasPrevSubmission: false,
|
||||
hasNextSubmission: false,
|
||||
allowNavigation: false,
|
||||
};
|
||||
SubmissionNavigation.propTypes = {
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
// redux
|
||||
allowNavigation: PropTypes.bool,
|
||||
activeIndex: PropTypes.number.isRequired,
|
||||
hasNextSubmission: PropTypes.bool,
|
||||
hasPrevSubmission: PropTypes.bool,
|
||||
@@ -65,6 +68,7 @@ SubmissionNavigation.propTypes = {
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
allowNavigation: selectors.requests.allowNavigation(state),
|
||||
activeIndex: selectors.grading.activeIndex(state),
|
||||
hasNextSubmission: selectors.grading.next.doesExist(state),
|
||||
hasPrevSubmission: selectors.grading.prev.doesExist(state),
|
||||
|
||||
@@ -21,6 +21,9 @@ jest.mock('data/redux/grading/selectors', () => ({
|
||||
activeIndex: (state) => ({ activeIndex: state }),
|
||||
selectionLength: (state) => ({ selectionlength: state }),
|
||||
}));
|
||||
jest.mock('data/redux/requests/selectors', () => ({
|
||||
allowNavigation: (state) => ({ allowNavigation: state }),
|
||||
}));
|
||||
|
||||
describe('SubmissionNavigation component', () => {
|
||||
describe('component', () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ exports[`SubmissionNavigation component component snapshot: no next submission (
|
||||
<IconButton
|
||||
alt="Load previous submission"
|
||||
className="ml-1"
|
||||
disabled={false}
|
||||
disabled={true}
|
||||
iconAs="Icon"
|
||||
onClick={[MockFunction this.props.loadPrev]}
|
||||
size="inline"
|
||||
@@ -71,7 +71,7 @@ exports[`SubmissionNavigation component component snapshot: no prev submission (
|
||||
<IconButton
|
||||
alt="Load next submission"
|
||||
className="ml-1"
|
||||
disabled={false}
|
||||
disabled={true}
|
||||
iconAs="Icon"
|
||||
onClick={[MockFunction this.props.loadNext]}
|
||||
size="inline"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ActionRow, Button } from '@edx/paragon';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { actions, selectors } from 'data/redux';
|
||||
import { RequestKeys } from 'data/constants/requests';
|
||||
|
||||
import StatusBadge from 'components/StatusBadge';
|
||||
import StartGradingButton from './components/StartGradingButton';
|
||||
@@ -17,15 +18,18 @@ import './ReviewActions.scss';
|
||||
export const ReviewActions = ({
|
||||
gradingStatus,
|
||||
toggleShowRubric,
|
||||
points: { pointsEarned, pointsPossible },
|
||||
score: { pointsEarned, pointsPossible },
|
||||
showRubric,
|
||||
username,
|
||||
isLoaded,
|
||||
}) => (
|
||||
<div>
|
||||
<ActionRow className="review-actions">
|
||||
<span className="review-actions-username">
|
||||
<span className="lead">{username}</span>
|
||||
<StatusBadge className="review-actions-status mr-3" status={gradingStatus} />
|
||||
{ gradingStatus && (
|
||||
<StatusBadge className="review-actions-status mr-3" status={gradingStatus} />
|
||||
)}
|
||||
<span className="small">
|
||||
{pointsEarned && (
|
||||
<FormattedMessage
|
||||
@@ -36,31 +40,41 @@ export const ReviewActions = ({
|
||||
</span>
|
||||
</span>
|
||||
<div className="review-actions-group">
|
||||
<Button variant="outline-primary" onClick={toggleShowRubric}>
|
||||
<FormattedMessage {...(showRubric ? messages.hideRubric : messages.showRubric)} />
|
||||
</Button>
|
||||
<StartGradingButton />
|
||||
{isLoaded && (
|
||||
<>
|
||||
<Button variant="outline-primary" onClick={toggleShowRubric}>
|
||||
<FormattedMessage {...(showRubric ? messages.hideRubric : messages.showRubric)} />
|
||||
</Button>
|
||||
<StartGradingButton />
|
||||
</>
|
||||
)}
|
||||
<SubmissionNavigation />
|
||||
</div>
|
||||
</ActionRow>
|
||||
</div>
|
||||
);
|
||||
ReviewActions.defaultProps = {
|
||||
isLoaded: false,
|
||||
gradingStatus: null,
|
||||
};
|
||||
ReviewActions.propTypes = {
|
||||
gradingStatus: PropTypes.string.isRequired,
|
||||
gradingStatus: PropTypes.string,
|
||||
username: PropTypes.string.isRequired,
|
||||
points: PropTypes.shape({
|
||||
score: PropTypes.shape({
|
||||
pointsEarned: PropTypes.number,
|
||||
pointsPossible: PropTypes.number,
|
||||
}).isRequired,
|
||||
showRubric: PropTypes.bool.isRequired,
|
||||
toggleShowRubric: PropTypes.func.isRequired,
|
||||
isLoaded: PropTypes.bool,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
username: selectors.grading.selected.username(state),
|
||||
gradingStatus: selectors.grading.selected.gradingStatus(state),
|
||||
points: selectors.grading.selected.points(state),
|
||||
score: selectors.grading.selected.score(state),
|
||||
showRubric: selectors.app.showRubric(state),
|
||||
isLoaded: selectors.requests.isCompleted(state, { requestKey: RequestKeys.fetchSubmission }),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { actions, selectors } from 'data/redux';
|
||||
import { RequestKeys } from 'data/constants/requests';
|
||||
|
||||
import { ReviewActions, mapStateToProps, mapDispatchToProps } from '.';
|
||||
|
||||
@@ -11,10 +12,13 @@ jest.mock('data/redux/app/selectors', () => ({
|
||||
jest.mock('data/redux/grading/selectors', () => ({
|
||||
selected: {
|
||||
gradingStatus: (state) => ({ gradingStatus: state }),
|
||||
points: (state) => ({ points: state }),
|
||||
score: (state) => ({ score: state }),
|
||||
username: (state) => ({ username: state }),
|
||||
},
|
||||
}));
|
||||
jest.mock('data/redux/requests/selectors', () => ({
|
||||
isCompleted: (state) => ({ isCompleted: state }),
|
||||
}));
|
||||
jest.mock('components/StatusBadge', () => 'StatusBadge');
|
||||
jest.mock('./components/StartGradingButton', () => 'StartGradingButton');
|
||||
jest.mock('./components/SubmissionNavigation', () => 'SubmissionNavigation');
|
||||
@@ -25,16 +29,19 @@ describe('ReviewActions component', () => {
|
||||
gradingStatus: 'grading-status',
|
||||
username: 'test-username',
|
||||
showRubric: false,
|
||||
points: { pointsEarned: 3, pointsPossible: 10 },
|
||||
score: { pointsEarned: 3, pointsPossible: 10 },
|
||||
};
|
||||
beforeEach(() => {
|
||||
props.toggleShowRubric = jest.fn().mockName('this.props.toggleShowRubric');
|
||||
});
|
||||
test('snapshot: do not show rubric', () => {
|
||||
test('snapshot: loading', () => {
|
||||
expect(shallow(<ReviewActions {...props} />)).toMatchSnapshot();
|
||||
});
|
||||
test('snapshot: show rubric, no points', () => {
|
||||
expect(shallow(<ReviewActions {...props} showRubric points={{}} />)).toMatchSnapshot();
|
||||
test('snapshot: do not show rubric', () => {
|
||||
expect(shallow(<ReviewActions {...props} isLoaded />)).toMatchSnapshot();
|
||||
});
|
||||
test('snapshot: show rubric, no score', () => {
|
||||
expect(shallow(<ReviewActions {...props} isLoaded showRubric score={{}} />)).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
describe('mapStateToProps', () => {
|
||||
@@ -43,14 +50,18 @@ describe('ReviewActions component', () => {
|
||||
beforeEach(() => {
|
||||
mapped = mapStateToProps(testState);
|
||||
});
|
||||
test('isLoaded loads from requests.isCompleted for fetchSubmissions', () => {
|
||||
const requestKey = RequestKeys.fetchSubmission;
|
||||
expect(mapped.isLoaded).toEqual(selectors.requests.isCompleted(testState, { requestKey }));
|
||||
});
|
||||
test('username loads from grading.selected.username', () => {
|
||||
expect(mapped.username).toEqual(selectors.grading.selected.username(testState));
|
||||
});
|
||||
test('gradingStatus loads from grading.selected.gradingStatus', () => {
|
||||
expect(mapped.gradingStatus).toEqual(selectors.grading.selected.gradingStatus(testState));
|
||||
});
|
||||
test('points loads from grading.selected.points', () => {
|
||||
expect(mapped.points).toEqual(selectors.grading.selected.points(testState));
|
||||
test('score loads from grading.selected.score', () => {
|
||||
expect(mapped.score).toEqual(selectors.grading.selected.score(testState));
|
||||
});
|
||||
test('showRubric loads from app.showRubric', () => {
|
||||
expect(mapped.showRubric).toEqual(selectors.app.showRubric(testState));
|
||||
|
||||
36
src/containers/ReviewModal/ReviewContent.jsx
Normal file
36
src/containers/ReviewModal/ReviewContent.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { Col, Row } from '@edx/paragon';
|
||||
|
||||
import { selectors } from 'data/redux';
|
||||
|
||||
import ResponseDisplay from 'containers/ResponseDisplay';
|
||||
import Rubric from 'containers/Rubric';
|
||||
|
||||
/**
|
||||
* <ReviewContent />
|
||||
*/
|
||||
export const ReviewContent = ({ showRubric }) => (
|
||||
<div className="content-block">
|
||||
<Row className="flex-nowrap">
|
||||
<Col><ResponseDisplay /></Col>
|
||||
{ showRubric && <Rubric /> }
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
ReviewContent.defaultProps = {
|
||||
showRubric: false,
|
||||
};
|
||||
ReviewContent.propTypes = {
|
||||
showRubric: PropTypes.bool,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
showRubric: selectors.app.showRubric(state),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ReviewContent);
|
||||
43
src/containers/ReviewModal/ReviewContent.test.jsx
Normal file
43
src/containers/ReviewModal/ReviewContent.test.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { selectors } from 'data/redux';
|
||||
import {
|
||||
ReviewContent,
|
||||
mapStateToProps,
|
||||
} from './ReviewContent';
|
||||
|
||||
jest.mock('data/redux', () => ({
|
||||
selectors: {
|
||||
app: {
|
||||
showRubric: (...args) => ({ showRubric: args }),
|
||||
},
|
||||
},
|
||||
}));
|
||||
jest.mock('containers/ResponseDisplay', () => 'ResponseDisplay');
|
||||
jest.mock('containers/Rubric', () => 'Rubric');
|
||||
|
||||
jest.useFakeTimers('modern');
|
||||
|
||||
describe('ReviewContent component', () => {
|
||||
describe('component', () => {
|
||||
describe('render tests', () => {
|
||||
test('snapshot (show rubric)', () => {
|
||||
expect(shallow(<ReviewContent />)).toMatchSnapshot();
|
||||
});
|
||||
test('snapshot (hide rubric)', () => {
|
||||
expect(shallow(<ReviewContent showRubric />)).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapStateToProps', () => {
|
||||
let mapped;
|
||||
const testState = { some: 'test-state' };
|
||||
beforeEach(() => {
|
||||
mapped = mapStateToProps(testState);
|
||||
});
|
||||
test('showRubric loads from app.showRubric', () => {
|
||||
expect(mapped.showRubric).toEqual(selectors.app.showRubric(testState));
|
||||
});
|
||||
});
|
||||
});
|
||||
51
src/containers/ReviewModal/ReviewError.jsx
Normal file
51
src/containers/ReviewModal/ReviewError.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
} from '@edx/paragon';
|
||||
import { Info } from '@edx/paragon/icons';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { thunkActions } from 'data/redux';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
/**
|
||||
* <ReviewError />
|
||||
*/
|
||||
export const ReviewError = ({ reload }) => (
|
||||
<Alert
|
||||
variant="danger"
|
||||
icon={Info}
|
||||
actions={[
|
||||
<Button onClick={reload}>
|
||||
<FormattedMessage {...messages.reloadSubmission} />
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Alert.Heading>
|
||||
<FormattedMessage {...messages.loadErrorHeading} />
|
||||
</Alert.Heading>
|
||||
<p>
|
||||
<FormattedMessage {...messages.loadErrorMessage} />
|
||||
</p>
|
||||
</Alert>
|
||||
);
|
||||
ReviewError.defaultProps = {
|
||||
};
|
||||
ReviewError.propTypes = {
|
||||
// redux
|
||||
reload: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = () => ({
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
reload: thunkActions.grading.loadSubmission,
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ReviewError);
|
||||
34
src/containers/ReviewModal/ReviewError.test.jsx
Normal file
34
src/containers/ReviewModal/ReviewError.test.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { thunkActions } from 'data/redux';
|
||||
|
||||
import {
|
||||
ReviewError,
|
||||
mapDispatchToProps,
|
||||
} from './ReviewError';
|
||||
|
||||
let el;
|
||||
jest.useFakeTimers('modern');
|
||||
|
||||
describe('ReviewError component', () => {
|
||||
const props = {};
|
||||
describe('component', () => {
|
||||
beforeEach(() => {
|
||||
props.reload = jest.fn();
|
||||
});
|
||||
describe('render tests', () => {
|
||||
beforeEach(() => {
|
||||
el = shallow(<ReviewError {...props} />);
|
||||
});
|
||||
test('snapshot', () => {
|
||||
expect(el).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapDispatchToProps', () => {
|
||||
it('loads reload from thunkActions.grading.reloadSubmission', () => {
|
||||
expect(mapDispatchToProps.reload).toEqual(thunkActions.grading.loadSubmission);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ReviewContent component component render tests snapshot (hide rubric) 1`] = `
|
||||
<div
|
||||
className="content-block"
|
||||
>
|
||||
<Row
|
||||
className="flex-nowrap"
|
||||
>
|
||||
<Col>
|
||||
<ResponseDisplay />
|
||||
</Col>
|
||||
<Rubric />
|
||||
</Row>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`ReviewContent component component render tests snapshot (show rubric) 1`] = `
|
||||
<div
|
||||
className="content-block"
|
||||
>
|
||||
<Row
|
||||
className="flex-nowrap"
|
||||
>
|
||||
<Col>
|
||||
<ResponseDisplay />
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,35 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ReviewError component component render tests snapshot 1`] = `
|
||||
<Alert
|
||||
actions={
|
||||
Array [
|
||||
<Button
|
||||
onClick={[MockFunction]}
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Reload submission"
|
||||
description="Reload button text in case of network failure"
|
||||
id="ora-grading.ReviewModal.reloadSubmission"
|
||||
/>
|
||||
</Button>,
|
||||
]
|
||||
}
|
||||
variant="danger"
|
||||
>
|
||||
<Alert.Heading>
|
||||
<FormattedMessage
|
||||
defaultMessage="Error loading submissions"
|
||||
description="Submission response load failure alert header"
|
||||
id="ora-grading.ReviewModal.loadErrorHeading"
|
||||
/>
|
||||
</Alert.Heading>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
defaultMessage="An error occurred while loading this submission. Try reloading this submission."
|
||||
description="Submission response load failure alert message"
|
||||
id="ora-grading.ReviewModal.loadErrorMessage1"
|
||||
/>
|
||||
</p>
|
||||
</Alert>
|
||||
`;
|
||||
74
src/containers/ReviewModal/__snapshots__/index.test.jsx.snap
Normal file
74
src/containers/ReviewModal/__snapshots__/index.test.jsx.snap
Normal file
@@ -0,0 +1,74 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ReviewModal component component snapshots closed 1`] = `
|
||||
<FullscreenModal
|
||||
beforeBodyNode={<ReviewActions />}
|
||||
className="review-modal"
|
||||
isOpen={false}
|
||||
modalBodyClassName="review-modal-body"
|
||||
onClose={[MockFunction this.onClose]}
|
||||
title="test-ora-name"
|
||||
>
|
||||
<LoadingMessage
|
||||
message={
|
||||
Object {
|
||||
"defaultMessage": "Loading response",
|
||||
"description": "loading text for submission response review screen",
|
||||
"id": "ora-grading.ReviewModal.loadingResponse",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</FullscreenModal>
|
||||
`;
|
||||
|
||||
exports[`ReviewModal component component snapshots error 1`] = `
|
||||
<FullscreenModal
|
||||
beforeBodyNode={<ReviewActions />}
|
||||
className="review-modal"
|
||||
isOpen={true}
|
||||
modalBodyClassName="review-modal-body"
|
||||
onClose={[MockFunction this.onClose]}
|
||||
title="test-ora-name"
|
||||
>
|
||||
<React.Fragment>
|
||||
<ReviewError />
|
||||
</React.Fragment>
|
||||
</FullscreenModal>
|
||||
`;
|
||||
|
||||
exports[`ReviewModal component component snapshots loading 1`] = `
|
||||
<FullscreenModal
|
||||
beforeBodyNode={<ReviewActions />}
|
||||
className="review-modal"
|
||||
isOpen={true}
|
||||
modalBodyClassName="review-modal-body"
|
||||
onClose={[MockFunction this.onClose]}
|
||||
title="test-ora-name"
|
||||
>
|
||||
<React.Fragment />
|
||||
<LoadingMessage
|
||||
message={
|
||||
Object {
|
||||
"defaultMessage": "Loading response",
|
||||
"description": "loading text for submission response review screen",
|
||||
"id": "ora-grading.ReviewModal.loadingResponse",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</FullscreenModal>
|
||||
`;
|
||||
|
||||
exports[`ReviewModal component component snapshots success 1`] = `
|
||||
<FullscreenModal
|
||||
beforeBodyNode={<ReviewActions />}
|
||||
className="review-modal"
|
||||
isOpen={true}
|
||||
modalBodyClassName="review-modal-body"
|
||||
onClose={[MockFunction this.onClose]}
|
||||
title="test-ora-name"
|
||||
>
|
||||
<React.Fragment>
|
||||
<ReviewContent />
|
||||
</React.Fragment>
|
||||
</FullscreenModal>
|
||||
`;
|
||||
@@ -2,18 +2,16 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
FullscreenModal,
|
||||
Row,
|
||||
Col,
|
||||
} from '@edx/paragon';
|
||||
import { FullscreenModal } from '@edx/paragon';
|
||||
|
||||
import { selectors, actions } from 'data/redux';
|
||||
import { RequestKeys } from 'data/constants/requests';
|
||||
|
||||
import ResponseDisplay from 'containers/ResponseDisplay';
|
||||
import Rubric from 'containers/Rubric';
|
||||
|
||||
import LoadingMessage from 'components/LoadingMessage';
|
||||
import ReviewActions from 'containers/ReviewActions';
|
||||
import ReviewError from './ReviewError';
|
||||
import ReviewContent from './ReviewContent';
|
||||
import messages from './messages';
|
||||
|
||||
import './ReviewModal.scss';
|
||||
|
||||
@@ -30,25 +28,29 @@ export class ReviewModal extends React.Component {
|
||||
this.props.setShowReview(false);
|
||||
}
|
||||
|
||||
get isLoading() {
|
||||
return !(this.props.hasError || this.props.isLoaded);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.response === null) {
|
||||
return null;
|
||||
}
|
||||
const { isOpen, isLoaded, hasError } = this.props;
|
||||
return (
|
||||
<FullscreenModal
|
||||
title={this.props.oraName}
|
||||
isOpen={this.props.isOpen}
|
||||
isOpen={isOpen}
|
||||
beforeBodyNode={<ReviewActions />}
|
||||
onClose={this.onClose}
|
||||
className="review-modal"
|
||||
modalBodyClassName="review-modal-body"
|
||||
>
|
||||
<div className="content-block">
|
||||
<Row className="flex-nowrap">
|
||||
<Col><ResponseDisplay /></Col>
|
||||
{ this.props.showRubric && <Rubric /> }
|
||||
</Row>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<>
|
||||
{isLoaded && <ReviewContent />}
|
||||
{hasError && <ReviewError />}
|
||||
</>
|
||||
)}
|
||||
{/* even if the modal is closed, in case we want to add transitions later */}
|
||||
{!(isLoaded || hasError) && <LoadingMessage message={messages.loadingResponse} />}
|
||||
</FullscreenModal>
|
||||
);
|
||||
}
|
||||
@@ -63,14 +65,16 @@ ReviewModal.propTypes = {
|
||||
text: PropTypes.node,
|
||||
}),
|
||||
setShowReview: PropTypes.func.isRequired,
|
||||
showRubric: PropTypes.bool.isRequired,
|
||||
isLoaded: PropTypes.bool.isRequired,
|
||||
hasError: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export const mapStateToProps = (state) => ({
|
||||
isOpen: selectors.app.showReview(state),
|
||||
oraName: selectors.app.ora.name(state),
|
||||
response: selectors.grading.selected.response(state),
|
||||
showRubric: selectors.app.showRubric(state),
|
||||
isLoaded: selectors.requests.isCompleted(state, { requestKey: RequestKeys.fetchSubmission }),
|
||||
hasError: selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchSubmission }),
|
||||
});
|
||||
|
||||
export const mapDispatchToProps = {
|
||||
|
||||
108
src/containers/ReviewModal/index.test.jsx
Normal file
108
src/containers/ReviewModal/index.test.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { selectors, actions } from 'data/redux';
|
||||
import { RequestKeys } from 'data/constants/requests';
|
||||
|
||||
import {
|
||||
ReviewModal,
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
} from '.';
|
||||
|
||||
let el;
|
||||
jest.useFakeTimers('modern');
|
||||
|
||||
jest.mock('data/redux', () => ({
|
||||
selectors: {
|
||||
app: {
|
||||
ora: { name: (...args) => ({ oraName: args }) },
|
||||
showReview: (...args) => ({ showReview: args }),
|
||||
},
|
||||
grading: {
|
||||
selected: { response: (...args) => ({ selectedResponse: args }) },
|
||||
},
|
||||
requests: {
|
||||
isCompleted: (...args) => ({ isCompleted: args }),
|
||||
isFailed: (...args) => ({ isFailed: args }),
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
app: {
|
||||
setShowReview: jest.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('containers/ReviewActions', () => 'ReviewActions');
|
||||
jest.mock('./ReviewError', () => 'ReviewError');
|
||||
jest.mock('./ReviewContent', () => 'ReviewContent');
|
||||
jest.mock('components/LoadingMessage', () => 'LoadingMessage');
|
||||
|
||||
const requestKey = RequestKeys.fetchSubmission;
|
||||
|
||||
describe('ReviewModal component', () => {
|
||||
const props = {
|
||||
oraName: 'test-ora-name',
|
||||
isOpen: false,
|
||||
response: { text: (<div>some text</div>) },
|
||||
showRubric: false,
|
||||
isLoaded: false,
|
||||
hasError: false,
|
||||
};
|
||||
describe('component', () => {
|
||||
beforeEach(() => {
|
||||
props.setShowReview = jest.fn();
|
||||
});
|
||||
describe('snapshots', () => {
|
||||
let render;
|
||||
beforeEach(() => {
|
||||
el = shallow(<ReviewModal {...props} />);
|
||||
el.instance().onClose = jest.fn().mockName('this.onClose');
|
||||
render = () => el.instance().render();
|
||||
});
|
||||
test('closed', () => {
|
||||
expect(render()).toMatchSnapshot();
|
||||
});
|
||||
test('loading', () => {
|
||||
el.setProps({ isOpen: true });
|
||||
expect(render()).toMatchSnapshot();
|
||||
});
|
||||
test('error', () => {
|
||||
el.setProps({ isOpen: true, hasError: true });
|
||||
expect(render()).toMatchSnapshot();
|
||||
});
|
||||
test('success', () => {
|
||||
el.setProps({ isOpen: true, isLoaded: true });
|
||||
expect(render()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('mapStateToProps', () => {
|
||||
let mapped;
|
||||
const testState = { some: 'test-state' };
|
||||
beforeEach(() => {
|
||||
mapped = mapStateToProps(testState);
|
||||
});
|
||||
test('oraName loads from app.ora.name', () => {
|
||||
expect(mapped.oraName).toEqual(selectors.app.ora.name(testState));
|
||||
});
|
||||
test('isOpen loads from app.showReview', () => {
|
||||
expect(mapped.isOpen).toEqual(selectors.app.showReview(testState));
|
||||
});
|
||||
test('response loads from grading.selected.response', () => {
|
||||
expect(mapped.response).toEqual(selectors.grading.selected.response(testState));
|
||||
});
|
||||
test('isLoaded loads from requests.isCompleted(fetchSubmission)', () => {
|
||||
expect(mapped.isLoaded).toEqual(selectors.requests.isCompleted(testState, { requestKey }));
|
||||
});
|
||||
test('hasError loads from requests.isFailed(fetchSubmission)', () => {
|
||||
expect(mapped.hasError).toEqual(selectors.requests.isFailed(testState, { requestKey }));
|
||||
});
|
||||
});
|
||||
describe('mapDispatchToProps', () => {
|
||||
it('loads setShowReview from thunkActions.app.setShowReview', () => {
|
||||
expect(mapDispatchToProps.setShowReview).toEqual(actions.app.setShowReview);
|
||||
});
|
||||
});
|
||||
});
|
||||
26
src/containers/ReviewModal/messages.js
Normal file
26
src/containers/ReviewModal/messages.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
loadErrorHeading: {
|
||||
id: 'ora-grading.ReviewModal.loadErrorHeading',
|
||||
defaultMessage: 'Error loading submissions',
|
||||
description: 'Submission response load failure alert header',
|
||||
},
|
||||
loadErrorMessage: {
|
||||
id: 'ora-grading.ReviewModal.loadErrorMessage1',
|
||||
defaultMessage: 'An error occurred while loading this submission. Try reloading this submission.',
|
||||
description: 'Submission response load failure alert message',
|
||||
},
|
||||
reloadSubmission: {
|
||||
id: 'ora-grading.ReviewModal.reloadSubmission',
|
||||
defaultMessage: 'Reload submission',
|
||||
description: 'Reload button text in case of network failure',
|
||||
},
|
||||
loadingResponse: {
|
||||
id: 'ora-grading.ReviewModal.loadingResponse',
|
||||
defaultMessage: 'Loading response',
|
||||
description: 'loading text for submission response review screen',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -43,6 +43,7 @@ const initialState = {
|
||||
activeIndex: null,
|
||||
current: {
|
||||
/**
|
||||
* submissionUUID: '',
|
||||
* gradeStatus: '',
|
||||
* response: {
|
||||
* text: '',
|
||||
@@ -115,13 +116,6 @@ export const updateCriterion = (state, orderNum, data) => {
|
||||
});
|
||||
};
|
||||
|
||||
const loadCurrentFromNeighbor = (neighbor, { lockStatus, gradeStatus, submissionUUID }) => ({
|
||||
response: neighbor.response,
|
||||
lockStatus,
|
||||
gradeStatus,
|
||||
submissionUUID,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const grading = createSlice({
|
||||
name: 'grading',
|
||||
@@ -130,31 +124,20 @@ const grading = createSlice({
|
||||
loadSubmission: (state, { payload }) => ({
|
||||
...state,
|
||||
current: { ...payload },
|
||||
activeIndex: 0,
|
||||
gradeData: {
|
||||
...state.gradeData,
|
||||
[payload.submissionUUID]: payload.gradeData,
|
||||
},
|
||||
}),
|
||||
preloadNext: (state, { payload }) => ({ ...state, next: payload }),
|
||||
preloadPrev: (state, { payload }) => ({ ...state, prev: payload }),
|
||||
loadNext: (state, { payload }) => ({
|
||||
loadNext: (state) => ({
|
||||
...state,
|
||||
prev: { response: state.current.response },
|
||||
current: loadCurrentFromNeighbor(state.next, payload),
|
||||
current: {},
|
||||
activeIndex: state.activeIndex + 1,
|
||||
gradeData: {
|
||||
...state.gradeData,
|
||||
[payload.submissionUUID]: payload.gradeData,
|
||||
},
|
||||
next: null,
|
||||
}),
|
||||
loadPrev: (state, { payload }) => ({
|
||||
loadPrev: (state) => ({
|
||||
...state,
|
||||
next: { response: state.current.response },
|
||||
current: loadCurrentFromNeighbor(state.prev, payload),
|
||||
gradeData: {
|
||||
...state.gradeData,
|
||||
[payload.submissionUUID]: payload.gradeData,
|
||||
},
|
||||
current: {},
|
||||
activeIndex: state.activeIndex - 1,
|
||||
prev: null,
|
||||
}),
|
||||
updateSelection: (state, { payload }) => ({
|
||||
...state,
|
||||
|
||||
@@ -120,7 +120,7 @@ selected.username = createSelector(
|
||||
/**
|
||||
* Returns the grade data for the selected submission
|
||||
* @return {obj} grade data
|
||||
* { points, overallFeedback, criteria }
|
||||
* { score, overallFeedback, criteria }
|
||||
*/
|
||||
selected.gradeData = createSelector(
|
||||
[module.selected.submissionUUID, module.simpleSelectors.gradeData],
|
||||
@@ -151,12 +151,12 @@ selected.criteriaGradeData = createSelector(
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns the points object associated with the grade
|
||||
* @return {obj} points object
|
||||
* Returns the score object associated with the grade
|
||||
* @return {obj} score object
|
||||
*/
|
||||
selected.points = createSelector(
|
||||
selected.score = createSelector(
|
||||
[module.selected.gradeData],
|
||||
(data) => ((data && data.points) ? data.points : {}),
|
||||
(data) => ((data && data.score) ? data.score : {}),
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { actions, reducer } from './reducer';
|
||||
export { default as selectors } from './selectors';
|
||||
|
||||
29
src/data/redux/requests/selectors.js
Normal file
29
src/data/redux/requests/selectors.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { StrictDict } from 'utils';
|
||||
import { RequestStates } from 'data/constants/requests';
|
||||
import * as module from './selectors';
|
||||
|
||||
export const requestStatus = (state, { requestKey }) => state.requests[requestKey];
|
||||
|
||||
const statusSelector = (fn) => (state, { requestKey }) => fn(state.requests[requestKey]);
|
||||
|
||||
export const isInactive = ({ status }) => status === RequestStates.inactive;
|
||||
export const isPending = ({ status }) => status === RequestStates.pending;
|
||||
export const isCompleted = ({ status }) => status === RequestStates.completed;
|
||||
export const isFailed = ({ status }) => status === RequestStates.failed;
|
||||
export const error = (request) => request.error;
|
||||
export const data = (request) => request.data;
|
||||
|
||||
export const allowNavigation = ({ requests }) => (
|
||||
!Object.keys(requests).some(requestKey => module.isPending(requests[requestKey]))
|
||||
);
|
||||
|
||||
export default StrictDict({
|
||||
requestStatus,
|
||||
allowNavigation,
|
||||
isInactive: statusSelector(isInactive),
|
||||
isPending: statusSelector(isPending),
|
||||
isCompleted: statusSelector(isCompleted),
|
||||
isFailed: statusSelector(isFailed),
|
||||
error: statusSelector(error),
|
||||
data: statusSelector(data),
|
||||
});
|
||||
@@ -1,74 +1,18 @@
|
||||
import { StrictDict } from 'utils';
|
||||
|
||||
import { RequestKeys } from 'data/constants/requests';
|
||||
import { actions, selectors } from 'data/redux';
|
||||
|
||||
import * as module from './grading';
|
||||
import requests from './requests';
|
||||
|
||||
/**
|
||||
* Prefetch the "next" submission in the selected queue. Only fetches the response info.
|
||||
*/
|
||||
export const prefetchNext = () => (dispatch, getState) => {
|
||||
dispatch(requests.fetchSubmissionResponse({
|
||||
requestKey: RequestKeys.prefetchNext,
|
||||
submissionUUID: selectors.grading.next.submissionUUID(getState()),
|
||||
onSuccess: (response) => {
|
||||
dispatch(actions.grading.preloadNext(response));
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Prefetch the "previous" submission in the selected queue. Only fetches the response info.
|
||||
*/
|
||||
export const prefetchPrev = () => (dispatch, getState) => {
|
||||
dispatch(requests.fetchSubmissionResponse({
|
||||
requestKey: RequestKeys.prefetchPrev,
|
||||
submissionUUID: selectors.grading.prev.submissionUUID(getState()),
|
||||
onSuccess: (response) => {
|
||||
dispatch(actions.grading.preloadPrev(response));
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the target neighbor submission's status, start grading if in progress,
|
||||
* dispatches load action with the response (injecting submissionUUID). If hasNeighbor,
|
||||
* also dispatches the prefetchAction to pre-fetch the new neighbor's response.
|
||||
* @param {string} submissionUUID - target submission id
|
||||
* @param {action} loadAction - redux action/thunkAction to load the submission status
|
||||
* @param {bool} hasNeighbor - is there a new neighbor to be pre-fetched?
|
||||
* @param {action} prefetchAction - redux action/thunkAction to prefetch the new
|
||||
* neighbor's response.
|
||||
*/
|
||||
export const fetchNeighbor = ({
|
||||
submissionUUID,
|
||||
loadAction,
|
||||
hasNeighbor,
|
||||
prefetchAction,
|
||||
}) => (dispatch) => {
|
||||
dispatch(requests.fetchSubmissionStatus({
|
||||
submissionUUID,
|
||||
onSuccess: (response) => {
|
||||
dispatch(loadAction({ ...response, submissionUUID }));
|
||||
if (hasNeighbor) { dispatch(prefetchAction()); }
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the current status for the "next" submission in the selected queue,
|
||||
* and calls loadNext with it to update the current selection index info.
|
||||
* If the new index has a next submission available, preload its response.
|
||||
*/
|
||||
export const loadNext = () => (dispatch, getState) => {
|
||||
dispatch(module.fetchNeighbor({
|
||||
loadAction: actions.grading.loadNext,
|
||||
hasNeighbor: selectors.grading.next.doesExist(getState()),
|
||||
prefetchAction: module.prefetchNext,
|
||||
submissionUUID: selectors.grading.next.submissionUUID(getState()),
|
||||
}));
|
||||
export const loadNext = () => (dispatch) => {
|
||||
dispatch(actions.grading.loadNext());
|
||||
dispatch(module.loadSubmission());
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -76,13 +20,9 @@ export const loadNext = () => (dispatch, getState) => {
|
||||
* and calls loadPrev with it to update the current selection index info.
|
||||
* If the new index has a previous submission available, preload its response.
|
||||
*/
|
||||
export const loadPrev = () => (dispatch, getState) => {
|
||||
dispatch(module.fetchNeighbor({
|
||||
loadAction: actions.grading.loadPrev,
|
||||
hasNeighbor: selectors.grading.prev.doesExist(getState()),
|
||||
prefetchAction: module.prefetchPrev,
|
||||
submissionUUID: selectors.grading.prev.submissionUUID(getState()),
|
||||
}));
|
||||
export const loadPrev = () => (dispatch) => {
|
||||
dispatch(actions.grading.loadPrev());
|
||||
dispatch(module.loadSubmission());
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -91,22 +31,18 @@ export const loadPrev = () => (dispatch, getState) => {
|
||||
* Then loads current selection and prefetches neighbors.
|
||||
* @param {string[]} submissionUUIDs - ordered list of submissionUUIDs for selected submissions
|
||||
*/
|
||||
export const loadSelectionForReview = (submissionUUIDs) => (dispatch, getState) => {
|
||||
export const loadSelectionForReview = (submissionUUIDs) => (dispatch) => {
|
||||
dispatch(actions.grading.updateSelection(submissionUUIDs));
|
||||
dispatch(actions.app.setShowReview(true));
|
||||
dispatch(module.loadSubmission());
|
||||
};
|
||||
|
||||
export const loadSubmission = () => (dispatch, getState) => {
|
||||
const submissionUUID = selectors.grading.selected.submissionUUID(getState());
|
||||
dispatch(requests.fetchSubmission({
|
||||
submissionUUID: submissionUUIDs[0],
|
||||
submissionUUID,
|
||||
onSuccess: (response) => {
|
||||
dispatch(actions.grading.updateSelection(submissionUUIDs));
|
||||
dispatch(actions.grading.loadSubmission({
|
||||
...response,
|
||||
submissionUUID: submissionUUIDs[0],
|
||||
}));
|
||||
dispatch(actions.app.setShowReview(true));
|
||||
if (selectors.grading.next.doesExist(getState())) {
|
||||
dispatch(module.prefetchNext());
|
||||
}
|
||||
if (selectors.grading.prev.doesExist(getState())) {
|
||||
dispatch(module.prefetchPrev());
|
||||
}
|
||||
dispatch(actions.grading.loadSubmission({ ...response, submissionUUID }));
|
||||
},
|
||||
}));
|
||||
};
|
||||
@@ -182,6 +118,7 @@ export default StrictDict({
|
||||
loadPrev,
|
||||
startGrading,
|
||||
cancelGrading,
|
||||
loadSubmission,
|
||||
stopGrading,
|
||||
submitGrade,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { RequestKeys } from 'data/constants/requests';
|
||||
import { gradingStatuses } from 'data/services/lms/constants';
|
||||
|
||||
import { actions, selectors } from 'data/redux';
|
||||
import * as thunkActions from './grading';
|
||||
|
||||
@@ -18,11 +15,9 @@ jest.mock('data/redux/app/selectors', () => ({
|
||||
|
||||
jest.mock('data/redux/grading/selectors', () => ({
|
||||
prev: {
|
||||
submissionUUID: (state) => ({ prevsubmissionUUID: state }),
|
||||
doesExist: jest.fn((state) => ({ prevDoesExist: state })),
|
||||
},
|
||||
next: {
|
||||
submissionUUID: (state) => ({ prevsubmissionUUID: state }),
|
||||
doesExist: jest.fn((state) => ({ nextDoesExist: state })),
|
||||
},
|
||||
selected: {
|
||||
@@ -33,8 +28,9 @@ jest.mock('data/redux/grading/selectors', () => ({
|
||||
|
||||
describe('grading thunkActions', () => {
|
||||
const testState = { some: 'testy-state' };
|
||||
const submissionUUID = 'test-submission-id';
|
||||
const selectedUUID = selectors.grading.selected.submissionUUID(testState);
|
||||
const response = 'test-response';
|
||||
const objResponse = { response };
|
||||
let dispatch;
|
||||
let dispatched;
|
||||
let actionArgs;
|
||||
@@ -49,210 +45,71 @@ describe('grading thunkActions', () => {
|
||||
dispatch = jest.fn((action) => ({ dispatch: action }));
|
||||
});
|
||||
|
||||
describe('prefetchNext', () => {
|
||||
describe('loadSubmission', () => {
|
||||
beforeEach(() => {
|
||||
getDispatched(thunkActions.prefetchNext());
|
||||
actionArgs = dispatched.fetchSubmissionResponse;
|
||||
getDispatched(thunkActions.loadSubmission());
|
||||
actionArgs = dispatched.fetchSubmission;
|
||||
});
|
||||
it('dispatches fetchSubmissionResponse with prefetchNext key and nextsubmissionUUID', () => {
|
||||
test('dispatches fetchSubmission', () => {
|
||||
expect(actionArgs).not.toEqual(undefined);
|
||||
expect(actionArgs.requestKey).toEqual(RequestKeys.prefetchNext);
|
||||
expect(actionArgs.submissionUUID).toEqual(selectors.grading.prev.submissionUUID(testState));
|
||||
});
|
||||
describe('on success', () => {
|
||||
test('dispatches preloadNext', () => {
|
||||
describe('fetchSubmissionArgs', () => {
|
||||
test('submissionUUID: selectors.grading.selected.submisssionUUID', () => {
|
||||
expect(actionArgs.submissionUUID).toEqual(
|
||||
selectedUUID,
|
||||
);
|
||||
});
|
||||
test('onSuccess: dispatches loadSubmission with response and submissionUUID', () => {
|
||||
dispatch.mockClear();
|
||||
actionArgs.onSuccess(response);
|
||||
expect(dispatch.mock.calls).toEqual([
|
||||
[actions.grading.preloadNext(response)],
|
||||
]);
|
||||
actionArgs.onSuccess(objResponse);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
actions.grading.loadSubmission({ ...objResponse, submissionUUID: selectedUUID }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('prefetchPrev', () => {
|
||||
beforeEach(() => {
|
||||
getDispatched(thunkActions.prefetchPrev());
|
||||
actionArgs = dispatched.fetchSubmissionResponse;
|
||||
});
|
||||
it('dispatches fetchSubmissionResponse with prefetchPrev key and next submissionUUID', () => {
|
||||
expect(actionArgs).not.toEqual(undefined);
|
||||
expect(actionArgs.requestKey).toEqual(RequestKeys.prefetchPrev);
|
||||
expect(actionArgs.submissionUUID).toEqual(selectors.grading.next.submissionUUID(testState));
|
||||
});
|
||||
describe('on success', () => {
|
||||
test('dispatches preloadPrev', () => {
|
||||
dispatch.mockClear();
|
||||
actionArgs.onSuccess(response);
|
||||
expect(dispatch.mock.calls).toEqual([
|
||||
[actions.grading.preloadPrev(response)],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchNeighbor', () => {
|
||||
const loadAction = actions.grading.loadNext;
|
||||
const prefetchAction = actions.grading.loadNext;
|
||||
|
||||
const submitAction = (hasNeighbor) => {
|
||||
getDispatched(thunkActions.fetchNeighbor({
|
||||
submissionUUID,
|
||||
loadAction,
|
||||
hasNeighbor,
|
||||
prefetchAction,
|
||||
}));
|
||||
actionArgs = dispatched.fetchSubmissionStatus;
|
||||
};
|
||||
|
||||
it('calls fetchSubmissionStatus with submissionUUID', () => {
|
||||
submitAction(false);
|
||||
expect(actionArgs).not.toEqual(undefined);
|
||||
expect(actionArgs.submissionUUID).toEqual(submissionUUID);
|
||||
});
|
||||
describe('onSuccess', () => {
|
||||
it('dispatches startGrading if lockStatus is in progress', () => {
|
||||
submitAction(false);
|
||||
dispatch.mockClear();
|
||||
actionArgs.onSuccess({ lockStatus: gradingStatuses.inProgress });
|
||||
});
|
||||
it('dispatches stopGrading if lockStatus is not in progress', () => {
|
||||
submitAction(false);
|
||||
dispatch.mockClear();
|
||||
actionArgs.onSuccess({ lockStatus: 'other status' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchNeigbor inheritors', () => {
|
||||
let fetchNeighbor;
|
||||
describe('loadSubmission inheritors', () => {
|
||||
let loadSubmission;
|
||||
beforeAll(() => {
|
||||
fetchNeighbor = thunkActions.fetchNeighbor;
|
||||
thunkActions.fetchNeighbor = args => ({ fetchNeighbor: args });
|
||||
loadSubmission = thunkActions.loadSubmission;
|
||||
thunkActions.loadSubmission = args => ({ loadSubmission: args });
|
||||
});
|
||||
afterAll(() => {
|
||||
thunkActions.fetchNeighbor = fetchNeighbor;
|
||||
thunkActions.loadSubmission = loadSubmission;
|
||||
});
|
||||
describe('loadNext', () => {
|
||||
beforeEach(() => {
|
||||
getDispatched(thunkActions.loadNext());
|
||||
actionArgs = dispatched.fetchNeighbor;
|
||||
});
|
||||
test('dispatches fetchNeighbor', () => {
|
||||
expect(actionArgs).not.toEqual(undefined);
|
||||
});
|
||||
describe('fetchNeighbor args', () => {
|
||||
const selGroup = selectors.grading.next;
|
||||
test('loadAction: actions.grading.loadNext', () => {
|
||||
expect(actionArgs.loadAction).toEqual(actions.grading.loadNext);
|
||||
});
|
||||
test('prefetchAction: module.prefetchNext', () => {
|
||||
expect(actionArgs.prefetchAction).toEqual(thunkActions.prefetchNext);
|
||||
});
|
||||
test('hasNeighbor: selectors.grading.next.doesExist', () => {
|
||||
expect(actionArgs.hasNeighbor).toEqual(selGroup.doesExist(testState));
|
||||
});
|
||||
test('submissionUUID: selectors.grading.next.submissionUUID', () => {
|
||||
expect(actionArgs.submissionUUID).toEqual(selGroup.submissionUUID(testState));
|
||||
});
|
||||
test('dispatches actions.grading.loadNext and then loadSubmission', () => {
|
||||
thunkActions.loadNext()(dispatch, getState);
|
||||
expect(dispatch.mock.calls).toEqual([
|
||||
[actions.grading.loadNext()],
|
||||
[thunkActions.loadSubmission()],
|
||||
]);
|
||||
});
|
||||
});
|
||||
describe('loadPrev', () => {
|
||||
beforeEach(() => {
|
||||
getDispatched(thunkActions.loadPrev());
|
||||
actionArgs = dispatched.fetchNeighbor;
|
||||
});
|
||||
test('dispatches fetchNeighbor', () => {
|
||||
expect(actionArgs).not.toEqual(undefined);
|
||||
});
|
||||
describe('fetchNeighbor args', () => {
|
||||
const selGroup = selectors.grading.prev;
|
||||
test('loadAction: actions.grading.loadPrev', () => {
|
||||
expect(actionArgs.loadAction).toEqual(actions.grading.loadPrev);
|
||||
});
|
||||
test('prefetchAction: module.prefetchPrev', () => {
|
||||
expect(actionArgs.prefetchAction).toEqual(thunkActions.prefetchPrev);
|
||||
});
|
||||
test('hasNeighbor: selectors.grading.prev.doesExist', () => {
|
||||
expect(actionArgs.hasNeighbor).toEqual(selGroup.doesExist(testState));
|
||||
});
|
||||
test('submissionUUID: selectors.grading.prev.submissionUUID', () => {
|
||||
expect(actionArgs.submissionUUID).toEqual(selGroup.submissionUUID(testState));
|
||||
});
|
||||
test('dispatches actions.grading.loadPrev and then loadSubmission', () => {
|
||||
thunkActions.loadPrev()(dispatch, getState);
|
||||
expect(dispatch.mock.calls).toEqual([
|
||||
[actions.grading.loadPrev()],
|
||||
[thunkActions.loadSubmission()],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadSelectionForReview', () => {
|
||||
const submissionUUIDs = [
|
||||
'submission-id-0',
|
||||
'submission-id-1',
|
||||
'submission-id-2',
|
||||
'submission-id-3',
|
||||
];
|
||||
let prefetchPrev;
|
||||
let prefetchNext;
|
||||
beforeAll(() => {
|
||||
prefetchNext = thunkActions.prefetchNext;
|
||||
prefetchPrev = thunkActions.prefetchPrev;
|
||||
thunkActions.prefetchNext = () => 'prefetch next';
|
||||
thunkActions.prefetchPrev = () => 'prefetch prev';
|
||||
});
|
||||
afterAll(() => {
|
||||
thunkActions.prefetchNext = prefetchNext;
|
||||
thunkActions.prefetchPrev = prefetchPrev;
|
||||
});
|
||||
beforeEach(() => {
|
||||
getDispatched(thunkActions.loadSelectionForReview(submissionUUIDs));
|
||||
actionArgs = dispatched.fetchSubmission;
|
||||
});
|
||||
it('dispatches fetchSubmission with first submissionUUID', () => {
|
||||
expect(actionArgs).not.toEqual(undefined);
|
||||
expect(actionArgs.submissionUUID).toEqual(submissionUUIDs[0]);
|
||||
});
|
||||
describe('onSuccess', () => {
|
||||
beforeEach(() => {
|
||||
dispatch.mockClear();
|
||||
actionArgs.onSuccess(response);
|
||||
});
|
||||
it('dispatches updateSelection with passed submissionUUIDs', () => {
|
||||
expect(dispatch.mock.calls).toContainEqual(
|
||||
describe('loadSelectionForReview', () => {
|
||||
const submissionUUIDs = [
|
||||
'submission-id-0',
|
||||
'submission-id-1',
|
||||
'submission-id-2',
|
||||
'submission-id-3',
|
||||
];
|
||||
test('dispatches actions.grading.updateSelection, actions.app.setShowReview(true), and then loadSubmission', () => {
|
||||
thunkActions.loadSelectionForReview(submissionUUIDs)(dispatch, getState);
|
||||
expect(dispatch.mock.calls).toEqual([
|
||||
[actions.grading.updateSelection(submissionUUIDs)],
|
||||
);
|
||||
});
|
||||
it('dispatches actions.grading.loadSubmission with response and first submission id', () => {
|
||||
expect(dispatch.mock.calls).toContainEqual(
|
||||
[actions.grading.loadSubmission({ ...response, submissionUUID: submissionUUIDs[0] })],
|
||||
);
|
||||
});
|
||||
it('dispatches app setShowReview(true)', () => {
|
||||
expect(dispatch.mock.calls).toContainEqual(
|
||||
[actions.app.setShowReview(true)],
|
||||
);
|
||||
});
|
||||
it('dispatches prefetchNext iff selectors.grading.next.doesExist', () => {
|
||||
// default configured to be truthy
|
||||
expect(dispatch.mock.calls).toContainEqual(
|
||||
[thunkActions.prefetchNext()],
|
||||
);
|
||||
selectors.grading.next.doesExist.mockReturnValue(false);
|
||||
dispatch.mockClear();
|
||||
actionArgs.onSuccess(response);
|
||||
expect(dispatch.mock.calls).not.toContainEqual(
|
||||
[thunkActions.prefetchNext()],
|
||||
);
|
||||
});
|
||||
it('dispatches prefetchPrev iff selectors.grading.prev.doesExist', () => {
|
||||
// default configured to be truthy
|
||||
expect(dispatch.mock.calls).toContainEqual(
|
||||
[thunkActions.prefetchPrev()],
|
||||
);
|
||||
selectors.grading.prev.doesExist.mockReturnValue(false);
|
||||
dispatch.mockClear();
|
||||
actionArgs.onSuccess(response);
|
||||
expect(dispatch.mock.calls).not.toContainEqual(
|
||||
[thunkActions.prefetchPrev()],
|
||||
);
|
||||
[thunkActions.loadSubmission()],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,9 @@ jest.mock('@edx/frontend-platform/i18n', () => {
|
||||
});
|
||||
|
||||
jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedComponents({
|
||||
Alert: {
|
||||
Heading: 'Alert.Heading',
|
||||
},
|
||||
AlertModal: 'AlertModal',
|
||||
ActionRow: 'ActionRow',
|
||||
Badge: 'Badge',
|
||||
@@ -28,6 +31,7 @@ jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedCompon
|
||||
Card: {
|
||||
Body: 'Card.Body',
|
||||
},
|
||||
Col: 'Col',
|
||||
Collapsible: {
|
||||
Advanced: 'Collapsible.Advanced',
|
||||
Body: 'Collapsible.Body',
|
||||
@@ -57,6 +61,7 @@ jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedCompon
|
||||
RadioSet: 'Form.RadioSet',
|
||||
},
|
||||
FormControlFeedback: 'FormControlFeedback',
|
||||
FullscreenModal: 'FullscreenModal',
|
||||
Hyperlink: 'Hyperlink',
|
||||
Icon: 'Icon',
|
||||
IconButton: 'IconButton',
|
||||
@@ -65,6 +70,7 @@ jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedCompon
|
||||
Popover: {
|
||||
Content: 'Popover.Content',
|
||||
},
|
||||
Row: 'Row',
|
||||
TextFilter: 'TextFilter',
|
||||
}));
|
||||
|
||||
|
||||
@@ -56,7 +56,6 @@ const submissionUUIDs = [
|
||||
fakeData.ids.submissionUUID(4),
|
||||
];
|
||||
const submissions = submissionUUIDs.map(id => fakeData.mockSubmission(id));
|
||||
const responses = submissions.map(({ response }) => response);
|
||||
const statuses = submissionUUIDs.map(id => fakeData.mockSubmissionStatus(id));
|
||||
|
||||
const resolveFns = {};
|
||||
@@ -137,40 +136,18 @@ const clickPrev = async () => {
|
||||
userEvent.click(el.getByLabelText('Load previous submission'));
|
||||
};
|
||||
|
||||
/**
|
||||
* Wait for the prev and next values to be populated based on the current selection index
|
||||
*/
|
||||
const waitForNeighbors = async (currentIndex) => {
|
||||
await waitFor(
|
||||
() => {
|
||||
const { prev, next } = getState().grading;
|
||||
expect(prev).toEqual(
|
||||
(currentIndex > 0) ? { response: responses[currentIndex - 1] } : null,
|
||||
);
|
||||
expect(next).toEqual(
|
||||
(currentIndex < 4) ? { response: responses[currentIndex + 1] } : null,
|
||||
);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wait for neighbors, and then verify prev, current, and next grading fields have the appropriate
|
||||
* data. Also ensure that the app is "grading" iff the "current" response's lockStatus is inProgress.
|
||||
*/
|
||||
const checkLoadedResponses = async (currentIndex) => {
|
||||
await waitForNeighbors(currentIndex);
|
||||
const { prev, current, next } = state.grading;
|
||||
await waitFor(() => expect(getState().grading.current.submissionUUID).toEqual(submissionUUIDs[currentIndex]));
|
||||
const { lockStatus, gradeStatus } = statuses[currentIndex];
|
||||
expect({ prev, current, next }).toEqual({
|
||||
prev: currentIndex > 0 ? ({ response: responses[currentIndex - 1] }) : null,
|
||||
current: {
|
||||
submissionUUID: submissionUUIDs[currentIndex],
|
||||
response: submissions[currentIndex].response,
|
||||
lockStatus,
|
||||
gradeStatus,
|
||||
},
|
||||
next: currentIndex < 4 ? ({ response: responses[currentIndex + 1] }) : null,
|
||||
expect(state.grading.current).toEqual({
|
||||
submissionUUID: submissionUUIDs[currentIndex],
|
||||
response: submissions[currentIndex].response,
|
||||
lockStatus,
|
||||
gradeStatus,
|
||||
});
|
||||
expect(state.app.showReview).toEqual(true);
|
||||
};
|
||||
@@ -219,11 +196,6 @@ describe('ESG app integration tests', () => {
|
||||
...fakeData.mockSubmission(submissionUUID),
|
||||
});
|
||||
});
|
||||
it('loads response for next submission', () => {
|
||||
expect(state.grading.next).toEqual({
|
||||
response: fakeData.mockSubmission(submissionUUIDs[1]).response,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('review navigation', () => {
|
||||
|
||||
Reference in New Issue
Block a user