feat: update lock/unlock logistic (#38)

* feat: update lock/unlock logistic

* chore: disable submit grade on lock pending

* chore: update mock for contested lock

* chore: update set lock success response

* chore: add failure reducer/action for set lock
This commit is contained in:
leangseu-edx
2021-12-10 14:57:24 -05:00
committed by GitHub
parent 91c874e20d
commit 7e49670a95
17 changed files with 382 additions and 22 deletions

View File

@@ -94,7 +94,7 @@ export class StartGradingButton extends React.Component {
variant="primary"
iconAfter={args.iconAfter}
onClick={this.handleClick}
disabled={this.props.isPending}
disabled={this.props.gradeIsPending || this.props.lockIsPending}
>
<FormattedMessage {...args.label} />
</Button>
@@ -119,11 +119,13 @@ StartGradingButton.propTypes = {
gradingStatus: PropTypes.string.isRequired,
startGrading: PropTypes.func.isRequired,
stopGrading: PropTypes.func.isRequired,
isPending: PropTypes.bool.isRequired,
gradeIsPending: PropTypes.bool.isRequired,
lockIsPending: PropTypes.bool.isRequired,
};
export const mapStateToProps = (state) => ({
isPending: selectors.requests.isPending(state, { requestKey: RequestKeys.submitGrade }),
gradeIsPending: selectors.requests.isPending(state, { requestKey: RequestKeys.submitGrade }),
lockIsPending: selectors.requests.isPending(state, { requestKey: RequestKeys.setLock }),
gradeStatus: selectors.grading.selected.gradeStatus(state),
gradingStatus: selectors.grading.selected.gradingStatus(state),
});

View File

@@ -36,7 +36,8 @@ let el;
describe('StartGradingButton component', () => {
describe('component', () => {
const props = {
isPending: false,
gradeIsPending: false,
lockIsPending: false,
};
beforeEach(() => {
props.startGrading = jest.fn().mockName('this.props.startGrading');
@@ -69,9 +70,14 @@ describe('StartGradingButton component', () => {
test('snapshot: ungraded (startGrading callback)', () => {
expect(mockedEl(statuses.ungraded).instance().render()).toMatchSnapshot();
});
test('snapshot: pending (disabled)', () => {
test('snapshot: grade pending (disabled)', () => {
el = mockedEl(statuses.ungraded);
el.setProps({ isPending: true });
el.setProps({ gradeIsPending: true });
expect(el.instance().render()).toMatchSnapshot();
});
test('snapshot: lock pending (disabled)', () => {
el = mockedEl(statuses.ungraded);
el.setProps({ lockIsPending: true });
expect(el.instance().render()).toMatchSnapshot();
});
test('snapshot: graded, confirmOverride (startGrading callback)', () => {
@@ -92,14 +98,22 @@ describe('StartGradingButton component', () => {
beforeEach(() => {
mapped = mapStateToProps(testState);
});
test('isPending loads from requests.isPending(submitGrade)', () => {
expect(mapped.isPending).toEqual(
test('gradeIsPending loads from requests.gradeIsPending(submitGrade)', () => {
expect(mapped.gradeIsPending).toEqual(
selectors.requests.isPending(
testState,
{ requestKey: RequestKeys.submitGrade },
),
);
});
test('lockIsPending loads from requests.lockIsPending(setLock)', () => {
expect(mapped.lockIsPending).toEqual(
selectors.requests.isPending(
testState,
{ requestKey: RequestKeys.setLock },
),
);
});
test('gradeStatus loads from grading.selected.gradeStatus', () => {
expect(mapped.gradeStatus).toEqual(selectors.grading.selected.gradeStatus(testState));
});

View File

@@ -1,5 +1,33 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`StartGradingButton component component snapshotes snapshot: grade pending (disabled) 1`] = `
<React.Fragment>
<Button
disabled={true}
iconAfter={[MockFunction icons.Highlight]}
onClick={[MockFunction this.handleClick]}
variant="primary"
>
<FormattedMessage
defaultMessage="Start grading"
description="Review pane button text to start grading"
id="ora-grading.ReviewActions.StartGradingButton.startGrading"
/>
</Button>
<OverrideGradeConfirmModal
isOpen={false}
onCancel={[MockFunction this.hideConfirmOverrideGrade]}
onConfirm={[MockFunction this.confirmOverrideGrade]}
/>
<StopGradingConfirmModal
isOpen={false}
isOverride={false}
onCancel={[MockFunction this.hideConfirmStopGrading]}
onConfirm={[MockFunction this.confirmStopGrading]}
/>
</React.Fragment>
`;
exports[`StartGradingButton component component snapshotes snapshot: graded, confirmOverride (startGrading callback) 1`] = `
<React.Fragment>
<Button
@@ -56,9 +84,7 @@ exports[`StartGradingButton component component snapshotes snapshot: inProgress,
</React.Fragment>
`;
exports[`StartGradingButton component component snapshotes snapshot: locked (null) 1`] = `null`;
exports[`StartGradingButton component component snapshotes snapshot: pending (disabled) 1`] = `
exports[`StartGradingButton component component snapshotes snapshot: lock pending (disabled) 1`] = `
<React.Fragment>
<Button
disabled={true}
@@ -86,6 +112,8 @@ exports[`StartGradingButton component component snapshotes snapshot: pending (di
</React.Fragment>
`;
exports[`StartGradingButton component component snapshotes snapshot: locked (null) 1`] = `null`;
exports[`StartGradingButton component component snapshotes snapshot: ungraded (startGrading callback) 1`] = `
<React.Fragment>
<Button

View File

@@ -0,0 +1,65 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { selectors } from 'data/redux';
import { RequestKeys, ErrorStatuses } from 'data/constants/requests';
import messages from './messages';
import ReviewError from './ReviewError';
/**
* <LockErrors />
*/
export class LockErrors extends React.Component {
get errorProp() {
if (this.errorStatus === ErrorStatuses.forbidden) {
return {
heading: messages.errorLockContestedHeading,
message: messages.errorLockContested,
};
}
return {
heading: messages.errorLockBadRequestHeading,
message: messages.errorLockBadRequest,
};
}
render() {
if (!this.props.isFailed) { return null; }
const { heading, message } = this.errorProp;
return (
<ReviewError
key="lockFailed"
headingMessage={heading}
>
<FormattedMessage {...message} />
</ReviewError>
);
}
}
LockErrors.defaultProps = {
errorStatus: undefined,
};
LockErrors.propTypes = {
// redux
isFailed: PropTypes.bool.isRequired,
errorStatus: PropTypes.number,
};
export const mapStateToProps = (state) => ({
isFailed: selectors.requests.isFailed(state, {
requestKey: RequestKeys.setLock,
}),
errorStatus: selectors.requests.errorStatus(state, {
requestKey: RequestKeys.setLock,
}),
});
export const mapDispatchToProps = {};
export default connect(mapStateToProps, mapDispatchToProps)(LockErrors);

View File

@@ -0,0 +1,62 @@
import React from 'react';
import { shallow } from 'enzyme';
import { selectors } from 'data/redux';
import { ErrorStatuses, RequestKeys } from 'data/constants/requests';
import {
LockErrors,
mapStateToProps,
} from './LockErrors';
jest.mock('data/redux', () => ({
selectors: {
requests: {
errorStatus: (...args) => ({ errorStatus: args }),
isFailed: (...args) => ({ isFailed: args }),
},
},
}));
let el;
jest.mock('./ReviewError', () => 'ReviewError');
const requestKey = RequestKeys.setLock;
describe('LockErrors component', () => {
const props = {
isFailed: true,
};
describe('component', () => {
beforeEach(() => {
el = shallow(<LockErrors {...props} />);
el.instance().dismissError = jest.fn().mockName('this.dismissError');
});
describe('snapshots', () => {
test('no failure', () => {
el.setProps({ isFailed: false });
expect(el.instance().render()).toMatchSnapshot();
});
test('snapshot: error with bad request', () => {
el.setProps({ errorStatus: ErrorStatuses.badRequest });
expect(el.instance().render()).toMatchSnapshot();
});
test('snapshot: error with conflicted lock', () => {
el.setProps({ errorStatus: ErrorStatuses.forbidden });
expect(el.instance().render()).toMatchSnapshot();
});
});
});
describe('mapStateToProps', () => {
let mapped;
const testState = { some: 'test-state' };
beforeEach(() => {
mapped = mapStateToProps(testState);
});
test('errorStatus loads from requests.errorStatus(setLock)', () => {
expect(mapped.errorStatus).toEqual(
selectors.requests.errorStatus(testState, { requestKey }),
);
});
});
});

View File

@@ -44,6 +44,9 @@ const ReviewError = ({
</Alert>
);
};
ReviewError.defaultProps = {
actions: {},
};
ReviewError.propTypes = {
actions: PropTypes.shape({
cancel: PropTypes.shape({
@@ -54,7 +57,7 @@ ReviewError.propTypes = {
onClick: PropTypes.func,
message: messageShape,
}),
}).isRequired,
}),
headingMessage: messageShape.isRequired,
children: PropTypes.node.isRequired,
};

View File

@@ -0,0 +1,39 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`LockErrors component component snapshots no failure 1`] = `null`;
exports[`LockErrors component component snapshots snapshot: error with bad request 1`] = `
<ReviewError
headingMessage={
Object {
"defaultMessage": "Invalid request. Please check your input.",
"description": "Error lock request for missing params",
"id": "ora-grading.ReviewModal.errorLockBadRequestHeading",
}
}
>
<FormattedMessage
defaultMessage="Invalid request. Please check your input."
description="Error lock request for missing params"
id="ora-grading.ReviewModal.errorLockBadRequest"
/>
</ReviewError>
`;
exports[`LockErrors component component snapshots snapshot: error with conflicted lock 1`] = `
<ReviewError
headingMessage={
Object {
"defaultMessage": "Invalid request. Please check your input.",
"description": "Error lock request for missing params",
"id": "ora-grading.ReviewModal.errorLockBadRequestHeading",
}
}
>
<FormattedMessage
defaultMessage="Invalid request. Please check your input."
description="Error lock request for missing params"
id="ora-grading.ReviewModal.errorLockBadRequest"
/>
</ReviewError>
`;

View File

@@ -4,5 +4,6 @@ exports[`ReviewErrors component component snapshot: no failure 1`] = `
<Fragment>
<FetchErrors />
<SubmitErrors />
<LockErrors />
</Fragment>
`;

View File

@@ -1,6 +1,7 @@
import React from 'react';
import FetchErrors from './FetchErrors';
import LockErrors from './LockErrors';
import SubmitErrors from './SubmitErrors';
/**
@@ -10,6 +11,7 @@ export const ReviewErrors = () => (
<>
<FetchErrors />
<SubmitErrors />
<LockErrors />
</>
);
ReviewErrors.defaultProps = {

View File

@@ -5,6 +5,7 @@ import { ReviewErrors } from '.';
jest.mock('./FetchErrors', () => 'FetchErrors');
jest.mock('./SubmitErrors', () => 'SubmitErrors');
jest.mock('./LockErrors', () => 'LockErrors');
describe('ReviewErrors component', () => {
describe('component', () => {

View File

@@ -47,7 +47,26 @@ const messages = defineMessages({
defaultMessage: 'It looks like someone else got here first! Your grade submission has been rejected',
description: 'Error Submitting Grade content',
},
errorLockContestedHeading: {
id: 'ora-grading.ReviewModal.errorLockContestedHeading',
defaultMessage: 'The lock owned by another user',
description: 'Error lock by someone else',
},
errorLockContested: {
id: 'ora-grading.ReviewModal.errorLockContested',
defaultMessage: 'The lock owned by another user',
description: 'Error lock by someone else',
},
errorLockBadRequestHeading: {
id: 'ora-grading.ReviewModal.errorLockBadRequestHeading',
defaultMessage: 'Invalid request. Please check your input.',
description: 'Error lock request for missing params',
},
errorLockBadRequest: {
id: 'ora-grading.ReviewModal.errorLockBadRequest',
defaultMessage: 'Invalid request. Please check your input.',
description: 'Error lock request for missing params',
},
});
export default StrictDict(messages);

View File

@@ -47,6 +47,7 @@ exports[`Rubric Container snapshot is grading 1`] = `
disabledStates={
Array [
"pending",
"complete",
]
}
labels={
@@ -75,6 +76,82 @@ exports[`Rubric Container snapshot is grading 1`] = `
</Card>
`;
exports[`Rubric Container snapshot is grading, lock is pending 1`] = `
<Card
className="grading-rubric-card"
>
<Card.Body
className="grading-rubric-body"
>
<h3>
<FormattedMessage
defaultMessage="Rubric"
description="Rubric interface label"
id="ora-grading.Rubric.rubric"
/>
</h3>
<hr
className="m-2.5"
/>
<CriterionContainer
isGrading={true}
orderNum={1}
/>
<CriterionContainer
isGrading={true}
orderNum={2}
/>
<CriterionContainer
isGrading={true}
orderNum={3}
/>
<CriterionContainer
isGrading={true}
orderNum={4}
/>
<CriterionContainer
isGrading={true}
orderNum={5}
/>
<hr />
<RubricFeedback />
</Card.Body>
<div
className="grading-rubric-footer"
>
<StatefulButton
disabledStates={
Array [
"pending",
"complete",
]
}
labels={
Object {
"complete": <FormattedMessage
defaultMessage="Grade Submitted"
description="Submit Grade button text after successful submission"
id="ora-grading.Rubric.gradeSubmitted"
/>,
"default": <FormattedMessage
defaultMessage="Submit grade"
description="Submit Grade button text"
id="ora-grading.Rubric.submitGrade"
/>,
"pending": <FormattedMessage
defaultMessage="Submitting grade"
description="Submit Grade button text while submitting"
id="ora-grading.Rubric.submittingGrade"
/>,
}
}
onClick={[MockFunction this.submitGradeHandler]}
state="pending"
/>
</div>
</Card>
`;
exports[`Rubric Container snapshot is grading, submit pending 1`] = `
<Card
className="grading-rubric-card"
@@ -122,6 +199,7 @@ exports[`Rubric Container snapshot is grading, submit pending 1`] = `
disabledStates={
Array [
"pending",
"complete",
]
}
labels={
@@ -240,6 +318,7 @@ exports[`Rubric Container snapshot submit completed 1`] = `
disabledStates={
Array [
"pending",
"complete",
]
}
labels={

View File

@@ -33,7 +33,7 @@ export class Rubric extends React.Component {
}
get submitButtonState() {
if (this.props.isPending) {
if (this.props.gradeIsPending || this.props.lockIsPending) {
return ButtonStates.pending;
}
if (this.props.isCompleted) {
@@ -68,7 +68,7 @@ export class Rubric extends React.Component {
<StatefulButton
onClick={this.submitGradeHandler}
state={this.submitButtonState}
disabledStates={[ButtonStates.pending]}
disabledStates={[ButtonStates.pending, ButtonStates.complete]}
labels={{
[ButtonStates.default]: <FormattedMessage {...messages.submitGrade} />,
[ButtonStates.pending]: <FormattedMessage {...messages.submittingGrade} />,
@@ -87,17 +87,17 @@ Rubric.defaultProps = {
Rubric.propTypes = {
isCompleted: PropTypes.bool.isRequired,
isGrading: PropTypes.bool.isRequired,
isPending: PropTypes.bool.isRequired,
gradeIsPending: PropTypes.bool.isRequired,
lockIsPending: PropTypes.bool.isRequired,
criteriaIndices: PropTypes.arrayOf(PropTypes.number),
submitGrade: PropTypes.func.isRequired,
};
const requestKey = RequestKeys.submitGrade;
export const mapStateToProps = (state) => ({
isCompleted: selectors.requests.isCompleted(state, { requestKey }),
isCompleted: selectors.requests.isCompleted(state, { requestKey: RequestKeys.submitGrade }),
isGrading: selectors.grading.selected.isGrading(state),
isPending: selectors.requests.isPending(state, { requestKey }),
gradeIsPending: selectors.requests.isPending(state, { requestKey: RequestKeys.submitGrade }),
lockIsPending: selectors.requests.isPending(state, { requestKey: RequestKeys.setLock }),
criteriaIndices: selectors.app.rubric.criteriaIndices(state),
});

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { shallow } from 'enzyme';
import { selectors, thunkActions } from 'data/redux';
import { RequestKeys } from 'data/constants/requests';
import { Rubric, mapStateToProps, mapDispatchToProps } from '.';
jest.mock('containers/CriterionContainer', () => 'CriterionContainer');
@@ -36,7 +37,8 @@ jest.mock('data/redux', () => ({
describe('Rubric Container', () => {
const props = {
isCompleted: false,
isPending: false,
gradeIsPending: false,
lockIsPending: false,
isGrading: true,
criteriaIndices: [1, 2, 3, 4, 5],
submitGrade: jest.fn().mockName('this.props.submitGrade'),
@@ -60,7 +62,11 @@ describe('Rubric Container', () => {
expect(el.instance().render()).toMatchSnapshot();
});
test('is grading, submit pending', () => {
el.setProps({ isPending: true });
el.setProps({ gradeIsPending: true });
expect(el.instance().render()).toMatchSnapshot();
});
test('is grading, lock is pending', () => {
el.setProps({ lockIsPending: true });
expect(el.instance().render()).toMatchSnapshot();
});
test('submit completed', () => {
@@ -111,6 +117,16 @@ describe('Rubric Container', () => {
test('isGrading from selectors.grading.selected.isGrading', () => {
expect(mapped.isGrading).toEqual(selectors.grading.selected.isGrading(testState));
});
test('gradeIsPending from selectors.requests.isPending(submitGrade)', () => {
expect(mapped.gradeIsPending).toEqual(
selectors.requests.isPending(testState, { requestKey: RequestKeys.submitGrade }),
);
});
test('lockIsPending from selectors.requests.isPending(setLock)', () => {
expect(mapped.lockIsPending).toEqual(selectors.requests.isPending(
testState, { requestKey: RequestKeys.setLock },
));
});
test('criteriaIndices from selectors.app.rubric.criteriaIndices', () => {
expect(mapped.criteriaIndices).toEqual(
selectors.app.rubric.criteriaIndices(testState),

View File

@@ -166,6 +166,10 @@ const grading = createSlice({
gradingData,
};
},
failSetLock: (state, { payload }) => ({
...state,
current: { ...state.current, lockStatus: payload.lockStatus },
}),
setRubricFeedback: (state, { payload }) => (
updateGradingData(state, { overallFeedback: payload })
),

View File

@@ -72,6 +72,11 @@ export const startGrading = () => (dispatch, getState) => {
}
dispatch(actions.grading.startGrading({ ...response, gradeData }));
},
onFailure: (error) => {
if (error.response.status === ErrorStatuses.forbidden) {
dispatch(actions.grading.failSetLock(error.response.data));
}
},
}));
};
@@ -87,6 +92,11 @@ export const cancelGrading = () => (dispatch, getState) => {
onSuccess: () => {
dispatch(module.stopGrading());
},
onFailure: (error) => {
if (error.response.status === ErrorStatuses.forbidden) {
dispatch(actions.grading.failSetLock(error.response.data));
}
},
}));
};

View File

@@ -67,6 +67,21 @@ export const genTestUtils = ({ dispatch }) => {
},
),
}),
setLock: StrictDict({
start: mockStart(RequestKeys.setLock),
success: () => {
dispatch(actions.requests.completeRequest({
requestKey: RequestKeys.setLock,
response: {
lockStatus: lockStatuses.inProgress,
},
}));
},
badRequestError: mockError(RequestKeys.setLock, ErrorStatuses.badRequest),
contestedLockError: mockError(RequestKeys.setLock, ErrorStatuses.forbidden, {
lockStatus: lockStatuses.locked,
}),
}),
};
};