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:
Ben Warzeski
2021-11-24 13:05:18 -05:00
committed by GitHub
parent 67d91f4f98
commit 7634e0ad7e
38 changed files with 1536 additions and 811 deletions

View 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;

View 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);

View 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);
});
});
});

View File

@@ -0,0 +1,4 @@
span.pgn__icon.breadcrumb-arrow {
width: 16px !important;
height: 16px !important;
};

View File

@@ -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>
</>

View 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));

View 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);
});
});
});

View File

@@ -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>
`;

View File

@@ -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>

View File

@@ -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>
`;

View File

@@ -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>
`;

View File

@@ -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);

View File

@@ -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);
});
});
});

View File

@@ -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;

View File

@@ -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"

View File

@@ -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),

View File

@@ -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', () => {

View File

@@ -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"

View File

@@ -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 = {

View File

@@ -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));

View 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);

View 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));
});
});
});

View 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);

View 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);
});
});
});

View File

@@ -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>
`;

View File

@@ -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>
`;

View 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>
`;

View File

@@ -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 = {

View 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);
});
});
});

View 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;

View File

@@ -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,

View File

@@ -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 : {}),
);
/**

View File

@@ -1 +1,2 @@
export { actions, reducer } from './reducer';
export { default as selectors } from './selectors';

View 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),
});

View File

@@ -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,
});

View File

@@ -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()],
]);
});
});
});

View File

@@ -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',
}));

View File

@@ -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', () => {