From 7634e0ad7e66e622469030a28bb7c2014df7009f Mon Sep 17 00:00:00 2001 From: Ben Warzeski Date: Wed, 24 Nov 2021 13:05:18 -0500 Subject: [PATCH] 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> --- src/components/LoadingMessage.jsx | 25 ++ src/containers/ListView/ListError.jsx | 63 +++++ src/containers/ListView/ListError.test.jsx | 67 +++++ src/containers/ListView/ListView.scss | 4 + .../ListView/ListViewBreadcrumb.jsx | 6 +- src/containers/ListView/SubmissionsTable.jsx | 157 +++++++++++ .../ListView/SubmissionsTable.test.jsx | 246 ++++++++++++++++++ .../__snapshots__/ListError.test.jsx.snap | 44 ++++ .../ListViewBreadcrumb.test.jsx.snap | 8 +- .../SubmissionsTable.test.jsx.snap | 123 +++++++++ .../__snapshots__/index.test.jsx.snap | 156 +++-------- src/containers/ListView/index.jsx | 145 ++--------- src/containers/ListView/index.test.jsx | 228 +++------------- src/containers/ListView/messages.js | 25 ++ .../__snapshots__/index.test.jsx.snap | 44 +++- .../components/SubmissionNavigation.jsx | 8 +- .../components/SubmissionNavigation.test.jsx | 3 + .../SubmissionNavigation.test.jsx.snap | 4 +- src/containers/ReviewActions/index.jsx | 32 ++- src/containers/ReviewActions/index.test.jsx | 25 +- src/containers/ReviewModal/ReviewContent.jsx | 36 +++ .../ReviewModal/ReviewContent.test.jsx | 43 +++ src/containers/ReviewModal/ReviewError.jsx | 51 ++++ .../ReviewModal/ReviewError.test.jsx | 34 +++ .../__snapshots__/ReviewContent.test.jsx.snap | 30 +++ .../__snapshots__/ReviewError.test.jsx.snap | 35 +++ .../__snapshots__/index.test.jsx.snap | 74 ++++++ src/containers/ReviewModal/index.jsx | 44 ++-- src/containers/ReviewModal/index.test.jsx | 108 ++++++++ src/containers/ReviewModal/messages.js | 26 ++ src/data/redux/grading/reducer.js | 35 +-- src/data/redux/grading/selectors.js | 10 +- src/data/redux/requests/index.js | 1 + src/data/redux/requests/selectors.js | 29 +++ src/data/redux/thunkActions/grading.js | 97 ++----- src/data/redux/thunkActions/grading.test.js | 235 ++++------------- src/setupTest.js | 6 + src/test/app.test.jsx | 40 +-- 38 files changed, 1536 insertions(+), 811 deletions(-) create mode 100644 src/components/LoadingMessage.jsx create mode 100644 src/containers/ListView/ListError.jsx create mode 100644 src/containers/ListView/ListError.test.jsx create mode 100644 src/containers/ListView/SubmissionsTable.jsx create mode 100644 src/containers/ListView/SubmissionsTable.test.jsx create mode 100644 src/containers/ListView/__snapshots__/ListError.test.jsx.snap create mode 100644 src/containers/ListView/__snapshots__/SubmissionsTable.test.jsx.snap create mode 100644 src/containers/ReviewModal/ReviewContent.jsx create mode 100644 src/containers/ReviewModal/ReviewContent.test.jsx create mode 100644 src/containers/ReviewModal/ReviewError.jsx create mode 100644 src/containers/ReviewModal/ReviewError.test.jsx create mode 100644 src/containers/ReviewModal/__snapshots__/ReviewContent.test.jsx.snap create mode 100644 src/containers/ReviewModal/__snapshots__/ReviewError.test.jsx.snap create mode 100644 src/containers/ReviewModal/__snapshots__/index.test.jsx.snap create mode 100644 src/containers/ReviewModal/index.test.jsx create mode 100644 src/containers/ReviewModal/messages.js create mode 100644 src/data/redux/requests/selectors.js diff --git a/src/components/LoadingMessage.jsx b/src/components/LoadingMessage.jsx new file mode 100644 index 0000000..584d1f2 --- /dev/null +++ b/src/components/LoadingMessage.jsx @@ -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'; + +/** + * + */ +export const LoadingMessage = ({ message }) => ( +
+ +

+
+); +LoadingMessage.defaultProps = { +}; +LoadingMessage.propTypes = { + message: PropTypes.shape({ + id: PropTypes.string, + defaultMessage: PropTypes.string, + }).isRequired, +}; + +export default LoadingMessage; diff --git a/src/containers/ListView/ListError.jsx b/src/containers/ListView/ListError.jsx new file mode 100644 index 0000000..b29b4dd --- /dev/null +++ b/src/containers/ListView/ListError.jsx @@ -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'; + +/** + * + */ +export const ListError = ({ courseId, initializeApp }) => ( + Reload Submissions, + ]} + > + + + +

+ + + + ), + }} + /> +

+
+); +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); diff --git a/src/containers/ListView/ListError.test.jsx b/src/containers/ListView/ListError.test.jsx new file mode 100644 index 0000000..0463e7a --- /dev/null +++ b/src/containers/ListView/ListError.test.jsx @@ -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(); + }); + 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); + }); + }); +}); diff --git a/src/containers/ListView/ListView.scss b/src/containers/ListView/ListView.scss index e69de29..9278823 100644 --- a/src/containers/ListView/ListView.scss +++ b/src/containers/ListView/ListView.scss @@ -0,0 +1,4 @@ +span.pgn__icon.breadcrumb-arrow { + width: 16px !important; + height: 16px !important; +}; diff --git a/src/containers/ListView/ListViewBreadcrumb.jsx b/src/containers/ListView/ListViewBreadcrumb.jsx index 25465d4..3f99281 100644 --- a/src/containers/ListView/ListViewBreadcrumb.jsx +++ b/src/containers/ListView/ListViewBreadcrumb.jsx @@ -17,13 +17,13 @@ import messages from './messages'; export const ListViewBreadcrumb = ({ courseId, oraName }) => ( <> - +

{oraName} - - + +

diff --git a/src/containers/ListView/SubmissionsTable.jsx b/src/containers/ListView/SubmissionsTable.jsx new file mode 100644 index 0000000..5b89b51 --- /dev/null +++ b/src/containers/ListView/SubmissionsTable.jsx @@ -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'; + +/** + * + */ +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 }) => (); + + 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 ( + + + + + + + ); + } +} +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)); diff --git a/src/containers/ListView/SubmissionsTable.test.jsx b/src/containers/ListView/SubmissionsTable.test.jsx new file mode 100644 index 0000000..2610dad --- /dev/null +++ b/src/containers/ListView/SubmissionsTable.test.jsx @@ -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(); + }); + describe('snapshots', () => { + beforeEach(() => { + mockMethod('handleViewAllResponsesClick'); + mockMethod('selectedBulkAction'); + mockMethod('formatDate'); + mockMethod('formatGrade'); + mockMethod('formatStatus'); + }); + test('snapshot: empty (no list data)', () => { + el = shallow(); + 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 / 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( + , + ); + }); + }); + 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); + }); + }); +}); diff --git a/src/containers/ListView/__snapshots__/ListError.test.jsx.snap b/src/containers/ListView/__snapshots__/ListError.test.jsx.snap new file mode 100644 index 0000000..3cdd0e3 --- /dev/null +++ b/src/containers/ListView/__snapshots__/ListError.test.jsx.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ListError component component render tests snapshot 1`] = ` + + Reload Submissions + , + ] + } + variant="danger" +> + + + +

+ + + , + } + } + /> +

+
+`; diff --git a/src/containers/ListView/__snapshots__/ListViewBreadcrumb.test.jsx.snap b/src/containers/ListView/__snapshots__/ListViewBreadcrumb.test.jsx.snap index 687656a..aac3626 100644 --- a/src/containers/ListView/__snapshots__/ListViewBreadcrumb.test.jsx.snap +++ b/src/containers/ListView/__snapshots__/ListViewBreadcrumb.test.jsx.snap @@ -7,8 +7,8 @@ exports[`ListViewBreadcrumb component component snapshot: empty (no list data) 1 destination="openResponseUrl(test-course-id)" >

diff --git a/src/containers/ListView/__snapshots__/SubmissionsTable.test.jsx.snap b/src/containers/ListView/__snapshots__/SubmissionsTable.test.jsx.snap new file mode 100644 index 0000000..5c373df --- /dev/null +++ b/src/containers/ListView/__snapshots__/SubmissionsTable.test.jsx.snap @@ -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`] = ` + + + + + + +`; diff --git a/src/containers/ListView/__snapshots__/index.test.jsx.snap b/src/containers/ListView/__snapshots__/index.test.jsx.snap index 8c1b0a2..651dc65 100644 --- a/src/containers/ListView/__snapshots__/index.test.jsx.snap +++ b/src/containers/ListView/__snapshots__/index.test.jsx.snap @@ -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`] = ` - - - - - - + + + +`; + +exports[`ListView component component render tests snapshots snapshot: loaded 1`] = ` + + + + + +`; + +exports[`ListView component component render tests snapshots snapshot: loading 1`] = ` + + +
+ +

+ +

+
`; diff --git a/src/containers/ListView/index.jsx b/src/containers/ListView/index.jsx index 20ae540..6107148 100644 --- a/src/containers/ListView/index.jsx +++ b/src/containers/ListView/index.jsx @@ -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 }) => (); - - 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 ( - - - - - - + { isLoaded && } + { hasError && } + { (!isLoaded && !hasError) && ( +
+ +

+
+ )}
); } } 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); diff --git a/src/containers/ListView/index.test.jsx b/src/containers/ListView/index.test.jsx index 7db3709..eca4530 100644 --- a/src/containers/ListView/index.test.jsx +++ b/src/containers/ListView/index.test.jsx @@ -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(); }); describe('snapshots', () => { - beforeEach(() => { - mockMethod('handleViewAllResponsesClick'); - mockMethod('selectedBulkAction'); - mockMethod('formatDate'); - mockMethod('formatGrade'); - mockMethod('formatStatus'); - }); - test('snapshot: empty (no list data)', () => { - el = shallow(); + 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 / 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( - , - ); - }); - }); - 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(); + 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); - }); }); }); diff --git a/src/containers/ListView/messages.js b/src/containers/ListView/messages.js index 953306e..71c9f33 100644 --- a/src/containers/ListView/messages.js +++ b/src/containers/ListView/messages.js @@ -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; diff --git a/src/containers/ReviewActions/__snapshots__/index.test.jsx.snap b/src/containers/ReviewActions/__snapshots__/index.test.jsx.snap index a2a131b..2331a6e 100644 --- a/src/containers/ReviewActions/__snapshots__/index.test.jsx.snap +++ b/src/containers/ReviewActions/__snapshots__/index.test.jsx.snap @@ -53,7 +53,49 @@ exports[`ReviewActions component component snapshot: do not show rubric 1`] = ` `; -exports[`ReviewActions component component snapshot: show rubric, no points 1`] = ` +exports[`ReviewActions component component snapshot: loading 1`] = ` +
+ + + + test-username + + + + + + +
+ +
+
+
+`; + +exports[`ReviewActions component component snapshot: show rubric, no score 1`] = `
( ({ + allowNavigation: selectors.requests.allowNavigation(state), activeIndex: selectors.grading.activeIndex(state), hasNextSubmission: selectors.grading.next.doesExist(state), hasPrevSubmission: selectors.grading.prev.doesExist(state), diff --git a/src/containers/ReviewActions/components/SubmissionNavigation.test.jsx b/src/containers/ReviewActions/components/SubmissionNavigation.test.jsx index 48a37cd..bac80d5 100644 --- a/src/containers/ReviewActions/components/SubmissionNavigation.test.jsx +++ b/src/containers/ReviewActions/components/SubmissionNavigation.test.jsx @@ -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', () => { diff --git a/src/containers/ReviewActions/components/__snapshots__/SubmissionNavigation.test.jsx.snap b/src/containers/ReviewActions/components/__snapshots__/SubmissionNavigation.test.jsx.snap index a95bc2a..01865ad 100644 --- a/src/containers/ReviewActions/components/__snapshots__/SubmissionNavigation.test.jsx.snap +++ b/src/containers/ReviewActions/components/__snapshots__/SubmissionNavigation.test.jsx.snap @@ -7,7 +7,7 @@ exports[`SubmissionNavigation component component snapshot: no next submission ( (
{username} - + { gradingStatus && ( + + )} {pointsEarned && (
- - + {isLoaded && ( + <> + + + + )}
); +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 = { diff --git a/src/containers/ReviewActions/index.test.jsx b/src/containers/ReviewActions/index.test.jsx index a5bd05d..b2e8023 100644 --- a/src/containers/ReviewActions/index.test.jsx +++ b/src/containers/ReviewActions/index.test.jsx @@ -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()).toMatchSnapshot(); }); - test('snapshot: show rubric, no points', () => { - expect(shallow()).toMatchSnapshot(); + test('snapshot: do not show rubric', () => { + expect(shallow()).toMatchSnapshot(); + }); + test('snapshot: show rubric, no score', () => { + expect(shallow()).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)); diff --git a/src/containers/ReviewModal/ReviewContent.jsx b/src/containers/ReviewModal/ReviewContent.jsx new file mode 100644 index 0000000..6f2a8c2 --- /dev/null +++ b/src/containers/ReviewModal/ReviewContent.jsx @@ -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'; + +/** + * + */ +export const ReviewContent = ({ showRubric }) => ( +
+ + + { showRubric && } + +
+); +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); diff --git a/src/containers/ReviewModal/ReviewContent.test.jsx b/src/containers/ReviewModal/ReviewContent.test.jsx new file mode 100644 index 0000000..ef1e73d --- /dev/null +++ b/src/containers/ReviewModal/ReviewContent.test.jsx @@ -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()).toMatchSnapshot(); + }); + test('snapshot (hide rubric)', () => { + expect(shallow()).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)); + }); + }); +}); diff --git a/src/containers/ReviewModal/ReviewError.jsx b/src/containers/ReviewModal/ReviewError.jsx new file mode 100644 index 0000000..d3d5503 --- /dev/null +++ b/src/containers/ReviewModal/ReviewError.jsx @@ -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'; + +/** + * + */ +export const ReviewError = ({ reload }) => ( + + + , + ]} + > + + + +

+ +

+
+); +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); diff --git a/src/containers/ReviewModal/ReviewError.test.jsx b/src/containers/ReviewModal/ReviewError.test.jsx new file mode 100644 index 0000000..461fcd5 --- /dev/null +++ b/src/containers/ReviewModal/ReviewError.test.jsx @@ -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(); + }); + test('snapshot', () => { + expect(el).toMatchSnapshot(); + }); + }); + }); + describe('mapDispatchToProps', () => { + it('loads reload from thunkActions.grading.reloadSubmission', () => { + expect(mapDispatchToProps.reload).toEqual(thunkActions.grading.loadSubmission); + }); + }); +}); diff --git a/src/containers/ReviewModal/__snapshots__/ReviewContent.test.jsx.snap b/src/containers/ReviewModal/__snapshots__/ReviewContent.test.jsx.snap new file mode 100644 index 0000000..86b6e90 --- /dev/null +++ b/src/containers/ReviewModal/__snapshots__/ReviewContent.test.jsx.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ReviewContent component component render tests snapshot (hide rubric) 1`] = ` +
+ + + + + + +
+`; + +exports[`ReviewContent component component render tests snapshot (show rubric) 1`] = ` +
+ + + + + +
+`; diff --git a/src/containers/ReviewModal/__snapshots__/ReviewError.test.jsx.snap b/src/containers/ReviewModal/__snapshots__/ReviewError.test.jsx.snap new file mode 100644 index 0000000..336df69 --- /dev/null +++ b/src/containers/ReviewModal/__snapshots__/ReviewError.test.jsx.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ReviewError component component render tests snapshot 1`] = ` + + + , + ] + } + variant="danger" +> + + + +

+ +

+
+`; diff --git a/src/containers/ReviewModal/__snapshots__/index.test.jsx.snap b/src/containers/ReviewModal/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000..a7bf68e --- /dev/null +++ b/src/containers/ReviewModal/__snapshots__/index.test.jsx.snap @@ -0,0 +1,74 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ReviewModal component component snapshots closed 1`] = ` +} + className="review-modal" + isOpen={false} + modalBodyClassName="review-modal-body" + onClose={[MockFunction this.onClose]} + title="test-ora-name" +> + + +`; + +exports[`ReviewModal component component snapshots error 1`] = ` +} + className="review-modal" + isOpen={true} + modalBodyClassName="review-modal-body" + onClose={[MockFunction this.onClose]} + title="test-ora-name" +> + + + + +`; + +exports[`ReviewModal component component snapshots loading 1`] = ` +} + className="review-modal" + isOpen={true} + modalBodyClassName="review-modal-body" + onClose={[MockFunction this.onClose]} + title="test-ora-name" +> + + + +`; + +exports[`ReviewModal component component snapshots success 1`] = ` +} + className="review-modal" + isOpen={true} + modalBodyClassName="review-modal-body" + onClose={[MockFunction this.onClose]} + title="test-ora-name" +> + + + + +`; diff --git a/src/containers/ReviewModal/index.jsx b/src/containers/ReviewModal/index.jsx index 53fed41..192b162 100644 --- a/src/containers/ReviewModal/index.jsx +++ b/src/containers/ReviewModal/index.jsx @@ -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 ( } onClose={this.onClose} className="review-modal" modalBodyClassName="review-modal-body" > -
- - - { this.props.showRubric && } - -
+ {isOpen && ( + <> + {isLoaded && } + {hasError && } + + )} + {/* even if the modal is closed, in case we want to add transitions later */} + {!(isLoaded || hasError) && }
); } @@ -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 = { diff --git a/src/containers/ReviewModal/index.test.jsx b/src/containers/ReviewModal/index.test.jsx new file mode 100644 index 0000000..1c3f70f --- /dev/null +++ b/src/containers/ReviewModal/index.test.jsx @@ -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: (
some text
) }, + showRubric: false, + isLoaded: false, + hasError: false, + }; + describe('component', () => { + beforeEach(() => { + props.setShowReview = jest.fn(); + }); + describe('snapshots', () => { + let render; + beforeEach(() => { + el = shallow(); + 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); + }); + }); +}); diff --git a/src/containers/ReviewModal/messages.js b/src/containers/ReviewModal/messages.js new file mode 100644 index 0000000..ddc8a99 --- /dev/null +++ b/src/containers/ReviewModal/messages.js @@ -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; diff --git a/src/data/redux/grading/reducer.js b/src/data/redux/grading/reducer.js index dad03df..b6279f8 100644 --- a/src/data/redux/grading/reducer.js +++ b/src/data/redux/grading/reducer.js @@ -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, diff --git a/src/data/redux/grading/selectors.js b/src/data/redux/grading/selectors.js index 736d966..070a577 100644 --- a/src/data/redux/grading/selectors.js +++ b/src/data/redux/grading/selectors.js @@ -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 : {}), ); /** diff --git a/src/data/redux/requests/index.js b/src/data/redux/requests/index.js index 27cac64..8abd5f9 100644 --- a/src/data/redux/requests/index.js +++ b/src/data/redux/requests/index.js @@ -1 +1,2 @@ export { actions, reducer } from './reducer'; +export { default as selectors } from './selectors'; diff --git a/src/data/redux/requests/selectors.js b/src/data/redux/requests/selectors.js new file mode 100644 index 0000000..15f8266 --- /dev/null +++ b/src/data/redux/requests/selectors.js @@ -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), +}); diff --git a/src/data/redux/thunkActions/grading.js b/src/data/redux/thunkActions/grading.js index 6d3afdf..17b5937 100644 --- a/src/data/redux/thunkActions/grading.js +++ b/src/data/redux/thunkActions/grading.js @@ -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, }); diff --git a/src/data/redux/thunkActions/grading.test.js b/src/data/redux/thunkActions/grading.test.js index 63fac4d..a13199b 100644 --- a/src/data/redux/thunkActions/grading.test.js +++ b/src/data/redux/thunkActions/grading.test.js @@ -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()], + ]); }); }); }); diff --git a/src/setupTest.js b/src/setupTest.js index de3d8b3..426a51e 100755 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -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', })); diff --git a/src/test/app.test.jsx b/src/test/app.test.jsx index b041436..0f9d65e 100644 --- a/src/test/app.test.jsx +++ b/src/test/app.test.jsx @@ -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', () => {