add grade selection to new moderation page
closes GRADE-1192 test plan: 1. Setup a course with AMM and a moderated assignment 2. Log in as various instructors and grade some students 3. Log in as the moderator 4. Visit the moderation page for the assignment 5. Select some grades 6. Verify the grades were selected * populated in Select element * highlighted in row 7. Verify a flash message indicate successful selection 8. Refresh the page 9. Verify the selected grades remain selected Change-Id: Ib07a5c7a1838ebc79bd660505f950a328d1eabfa Reviewed-on: https://gerrit.instructure.com/151602 Tested-by: Jenkins Reviewed-by: Adrian Packel <apackel@instructure.com> Reviewed-by: Keith T. Garner <kgarner@instructure.com> QA-Review: Gary Mei <gmei@instructure.com> Product-Review: Sidharth Oberoi <soberoi@instructure.com>
This commit is contained in:
parent
882df38a7b
commit
c08bfca4c2
|
@ -17,12 +17,13 @@
|
|||
*/
|
||||
|
||||
import {Component} from 'react'
|
||||
import {oneOf} from 'prop-types'
|
||||
import {oneOf, shape} from 'prop-types'
|
||||
import {connect} from 'react-redux'
|
||||
import I18n from 'i18n!assignment_grade_summary'
|
||||
|
||||
import {showFlashAlert} from '../../../shared/FlashAlert'
|
||||
import * as AssignmentActions from '../assignment/AssignmentActions'
|
||||
import * as GradeActions from '../grades/GradeActions'
|
||||
import * as StudentActions from '../students/StudentActions'
|
||||
|
||||
function enumeratedStatuses(actions) {
|
||||
|
@ -82,6 +83,7 @@ class FlashMessageHolder extends Component {
|
|||
static propTypes = {
|
||||
loadStudentsStatus: oneOf(enumeratedStatuses(StudentActions)),
|
||||
publishGradesStatus: oneOf(assignmentStatuses),
|
||||
selectProvisionalGradeStatuses: shape({}).isRequired,
|
||||
unmuteAssignmentStatus: oneOf(enumeratedStatuses(AssignmentActions))
|
||||
}
|
||||
|
||||
|
@ -106,6 +108,28 @@ class FlashMessageHolder extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
if (changes.selectProvisionalGradeStatuses) {
|
||||
Object.keys(nextProps.selectProvisionalGradeStatuses).forEach(studentId => {
|
||||
if (
|
||||
nextProps.selectProvisionalGradeStatuses[studentId] !==
|
||||
this.props.selectProvisionalGradeStatuses[studentId]
|
||||
) {
|
||||
const status = nextProps.selectProvisionalGradeStatuses[studentId]
|
||||
if (status === GradeActions.SUCCESS) {
|
||||
showFlashAlert({
|
||||
message: I18n.t('Grade saved.'),
|
||||
type: 'success'
|
||||
})
|
||||
} else if (status === GradeActions.FAILURE) {
|
||||
showFlashAlert({
|
||||
message: I18n.t('There was a problem saving the grade.'),
|
||||
type: 'error'
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (changes.publishGradesStatus) {
|
||||
announcePublishGradesStatus(nextProps.publishGradesStatus)
|
||||
}
|
||||
|
@ -124,6 +148,7 @@ function mapStateToProps(state) {
|
|||
return {
|
||||
loadStudentsStatus: state.students.loadStudentsStatus,
|
||||
publishGradesStatus: state.assignment.publishGradesStatus,
|
||||
selectProvisionalGradeStatuses: state.grades.selectProvisionalGradeStatuses,
|
||||
unmuteAssignmentStatus: state.assignment.unmuteAssignmentStatus
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* Copyright (C) 2018 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, {Component} from 'react'
|
||||
import {bool, number, shape, string} from 'prop-types'
|
||||
import Text from '@instructure/ui-elements/lib/components/Text'
|
||||
import View from '@instructure/ui-layout/lib/components/View'
|
||||
import I18n from 'i18n!assignment_grade_summary'
|
||||
|
||||
export default class GradeIndicator extends Component {
|
||||
static propTypes = {
|
||||
gradeInfo: shape({
|
||||
grade: string,
|
||||
graderId: string.isRequired,
|
||||
id: string.isRequired,
|
||||
score: number,
|
||||
selected: bool.isRequired,
|
||||
studentId: string.isRequired
|
||||
})
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
gradeInfo: null
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
return Object.keys(nextProps).some(key => this.props[key] !== nextProps[key])
|
||||
}
|
||||
|
||||
render() {
|
||||
const {gradeInfo} = this.props
|
||||
const selected = gradeInfo && gradeInfo.selected
|
||||
let textColor = selected ? 'primary-inverse' : 'primary'
|
||||
textColor = gradeInfo ? textColor : 'secondary'
|
||||
|
||||
return (
|
||||
<View
|
||||
background={selected ? 'inverse' : 'default'}
|
||||
borderRadius="small"
|
||||
borderWidth={selected ? 'small' : '0'}
|
||||
padding="xx-small small"
|
||||
>
|
||||
<Text color={textColor}>
|
||||
{gradeInfo && gradeInfo.score != null ? I18n.n(gradeInfo.score) : '–'}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* Copyright (C) 2018 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, {Component} from 'react'
|
||||
import {arrayOf, func, oneOf, shape, string} from 'prop-types'
|
||||
import ScreenReaderContent from '@instructure/ui-a11y/lib/components/ScreenReaderContent'
|
||||
import Select from '@instructure/ui-forms/lib/components/Select'
|
||||
import I18n from 'i18n!assignment_grade_summary'
|
||||
|
||||
import {FAILURE, STARTED, SUCCESS} from '../../grades/GradeActions'
|
||||
|
||||
export default class GradeSelect extends Component {
|
||||
static propTypes = {
|
||||
graders: arrayOf(
|
||||
shape({
|
||||
graderName: string,
|
||||
graderId: string.isRequired
|
||||
})
|
||||
).isRequired,
|
||||
grades: shape({}).isRequired,
|
||||
onClose: func,
|
||||
onOpen: func,
|
||||
onSelect: func,
|
||||
selectProvisionalGradeStatus: oneOf([FAILURE, STARTED, SUCCESS]),
|
||||
studentName: string.isRequired
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
onClose() {},
|
||||
onOpen() {},
|
||||
onSelect() {},
|
||||
selectProvisionalGradeStatus: null
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
this.handleSelect = this.handleSelect.bind(this)
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
return Object.keys(nextProps).some(key => this.props[key] !== nextProps[key])
|
||||
}
|
||||
|
||||
handleSelect(_event, option) {
|
||||
const gradeInfo = this.props.grades[option.value]
|
||||
if (!gradeInfo.selected && this.props.onSelect) {
|
||||
this.props.onSelect(gradeInfo)
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {graders, grades} = this.props
|
||||
const gradeOptions = []
|
||||
|
||||
let selectedOption
|
||||
for (let i = 0; i < graders.length; i++) {
|
||||
const grader = graders[i]
|
||||
const gradeInfo = grades[grader.graderId]
|
||||
|
||||
if (gradeInfo != null) {
|
||||
const option = {
|
||||
label: `${I18n.n(gradeInfo.score)} (${grader.graderName})`,
|
||||
value: gradeInfo.graderId
|
||||
}
|
||||
gradeOptions.push(option)
|
||||
|
||||
if (gradeInfo.selected) {
|
||||
selectedOption = option
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedOption) {
|
||||
gradeOptions.unshift({label: '–', value: 'no-selection'})
|
||||
selectedOption = 'no-selection'
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
aria-readonly={!this.props.onSelect || this.props.selectProvisionalGradeStatus === STARTED}
|
||||
key={
|
||||
/*
|
||||
* TODO: This forces a unique instance per-student, which hurts
|
||||
* performance. Remove this key entirely once the commit from
|
||||
* INSTUI-1199 has been published to npm and pulled into Canvas.
|
||||
*/
|
||||
this.props.studentName
|
||||
}
|
||||
label={
|
||||
<ScreenReaderContent>
|
||||
{I18n.t('Grade for %{studentName}', {studentName: this.props.studentName})}
|
||||
</ScreenReaderContent>
|
||||
}
|
||||
onChange={this.handleSelect}
|
||||
onClose={this.props.onClose}
|
||||
onOpen={this.props.onOpen}
|
||||
selectedOption={selectedOption}
|
||||
>
|
||||
{gradeOptions.map(gradeOption => (
|
||||
<option key={gradeOption.value} value={gradeOption.value}>
|
||||
{gradeOption.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -36,12 +36,18 @@ export default class Grid extends Component {
|
|||
).isRequired,
|
||||
grades: shape({}).isRequired,
|
||||
horizontalScrollRef: func.isRequired,
|
||||
onGradeSelect: func,
|
||||
rows: arrayOf(
|
||||
shape({
|
||||
studentId: string.isRequired,
|
||||
studentName: string.isRequired
|
||||
}).isRequired
|
||||
).isRequired
|
||||
).isRequired,
|
||||
selectProvisionalGradeStatuses: shape({}).isRequired
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
onGradeSelect: null
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
|
@ -62,19 +68,20 @@ export default class Grid extends Component {
|
|||
<Text>{I18n.t('Student')}</Text>
|
||||
</th>
|
||||
|
||||
{this.props.graders.map((grader, index) => (
|
||||
{this.props.graders.map(grader => (
|
||||
<th
|
||||
className="GradesGrid__GraderHeader"
|
||||
key={grader.graderId}
|
||||
role="columnheader"
|
||||
scope="col"
|
||||
>
|
||||
<Text>
|
||||
{grader.graderName ||
|
||||
I18n.t('Grader %{graderNumber}', {graderNumber: I18n.n(index + 1)})}
|
||||
</Text>
|
||||
<Text>{grader.graderName}</Text>
|
||||
</th>
|
||||
))}
|
||||
|
||||
<th className="GradesGrid__FinalGradeHeader" role="columnheader" scope="col">
|
||||
<Text>{I18n.t('Final Grade')}</Text>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
|
@ -84,7 +91,11 @@ export default class Grid extends Component {
|
|||
graders={this.props.graders}
|
||||
grades={this.props.grades[row.studentId]}
|
||||
key={index /* index used for performance reasons */}
|
||||
onGradeSelect={this.props.onGradeSelect}
|
||||
row={row}
|
||||
selectProvisionalGradeStatus={
|
||||
this.props.selectProvisionalGradeStatuses[row.studentId]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
|
@ -17,14 +17,12 @@
|
|||
*/
|
||||
|
||||
import React, {Component} from 'react'
|
||||
import {arrayOf, shape, string} from 'prop-types'
|
||||
import {arrayOf, func, oneOf, shape, string} from 'prop-types'
|
||||
import Text from '@instructure/ui-elements/lib/components/Text'
|
||||
import I18n from 'i18n!assignment_grade_summary'
|
||||
|
||||
function getGrade(graderId, grades) {
|
||||
const gradeInfo = grades[graderId]
|
||||
return gradeInfo && gradeInfo.score != null ? I18n.n(gradeInfo.score) : '–'
|
||||
}
|
||||
import {FAILURE, STARTED, SUCCESS} from '../../grades/GradeActions'
|
||||
import GradeIndicator from './GradeIndicator'
|
||||
import GradeSelect from './GradeSelect'
|
||||
|
||||
export default class GridRow extends Component {
|
||||
static propTypes = {
|
||||
|
@ -35,14 +33,18 @@ export default class GridRow extends Component {
|
|||
})
|
||||
).isRequired,
|
||||
grades: shape({}),
|
||||
onGradeSelect: func,
|
||||
row: shape({
|
||||
studentId: string.isRequired,
|
||||
studentName: string.isRequired
|
||||
}).isRequired
|
||||
}).isRequired,
|
||||
selectProvisionalGradeStatus: oneOf([FAILURE, STARTED, SUCCESS])
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
grades: {}
|
||||
grades: {},
|
||||
onGradeSelect: null,
|
||||
selectProvisionalGradeStatus: null
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
|
@ -61,10 +63,20 @@ export default class GridRow extends Component {
|
|||
|
||||
return (
|
||||
<td className={classNames.join(' ')} key={grader.graderId} role="cell">
|
||||
<Text>{getGrade(grader.graderId, this.props.grades)}</Text>
|
||||
<GradeIndicator gradeInfo={this.props.grades[grader.graderId]} />
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
|
||||
<td className="GradesGrid__FinalGradeCell" role="cell">
|
||||
<GradeSelect
|
||||
graders={this.props.graders}
|
||||
grades={this.props.grades}
|
||||
onSelect={this.props.onGradeSelect}
|
||||
selectProvisionalGradeStatus={this.props.selectProvisionalGradeStatus}
|
||||
studentName={this.props.row.studentName}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
*/
|
||||
|
||||
import React, {Component} from 'react'
|
||||
import {arrayOf, shape, string} from 'prop-types'
|
||||
import {arrayOf, func, shape, string} from 'prop-types'
|
||||
import View from '@instructure/ui-layout/lib/components/View'
|
||||
import I18n from 'i18n!assignment_grade_summary'
|
||||
|
||||
|
@ -56,6 +56,8 @@ export default class GradesGrid extends Component {
|
|||
})
|
||||
).isRequired,
|
||||
grades: shape({}).isRequired,
|
||||
onGradeSelect: func,
|
||||
selectProvisionalGradeStatuses: shape({}).isRequired,
|
||||
students: arrayOf(
|
||||
shape({
|
||||
displayName: string,
|
||||
|
@ -64,6 +66,10 @@ export default class GradesGrid extends Component {
|
|||
).isRequired
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
onGradeSelect: null
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
|
@ -98,7 +104,9 @@ export default class GradesGrid extends Component {
|
|||
graders={this.props.graders}
|
||||
grades={this.props.grades}
|
||||
horizontalScrollRef={props.horizontalScrollRef}
|
||||
onGradeSelect={this.props.onGradeSelect}
|
||||
rows={rows}
|
||||
selectProvisionalGradeStatuses={this.props.selectProvisionalGradeStatuses}
|
||||
/>
|
||||
)}
|
||||
</FocusableView>
|
||||
|
|
|
@ -17,13 +17,14 @@
|
|||
*/
|
||||
|
||||
import React, {Component} from 'react'
|
||||
import {arrayOf, func, shape, string} from 'prop-types'
|
||||
import {arrayOf, bool, func, shape, string} from 'prop-types'
|
||||
import {connect} from 'react-redux'
|
||||
import Spinner from '@instructure/ui-elements/lib/components/Spinner'
|
||||
import View from '@instructure/ui-layout/lib/components/View'
|
||||
import I18n from 'i18n!assignment_grade_summary'
|
||||
|
||||
import '../../../context_cards/StudentContextCardTrigger'
|
||||
import {selectProvisionalGrade} from '../grades/GradeActions'
|
||||
import {loadStudents} from '../students/StudentActions'
|
||||
import FlashMessageHolder from './FlashMessageHolder'
|
||||
import GradesGrid from './GradesGrid'
|
||||
|
@ -36,8 +37,11 @@ class Layout extends Component {
|
|||
graderId: string.isRequired
|
||||
})
|
||||
).isRequired,
|
||||
gradesPublished: bool.isRequired,
|
||||
loadStudents: func.isRequired,
|
||||
provisionalGrades: shape({}).isRequired,
|
||||
selectGrade: func.isRequired,
|
||||
selectProvisionalGradeStatuses: shape({}).isRequired,
|
||||
students: arrayOf(
|
||||
shape({
|
||||
id: string.isRequired
|
||||
|
@ -52,6 +56,8 @@ class Layout extends Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
const onGradeSelect = this.props.gradesPublished ? null : this.props.selectGrade
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FlashMessageHolder />
|
||||
|
@ -64,6 +70,8 @@ class Layout extends Component {
|
|||
<GradesGrid
|
||||
graders={this.props.graders}
|
||||
grades={this.props.provisionalGrades}
|
||||
onGradeSelect={onGradeSelect}
|
||||
selectProvisionalGradeStatuses={this.props.selectProvisionalGradeStatuses}
|
||||
students={this.props.students}
|
||||
/>
|
||||
) : (
|
||||
|
@ -79,7 +87,9 @@ class Layout extends Component {
|
|||
function mapStateToProps(state) {
|
||||
return {
|
||||
graders: state.context.graders,
|
||||
gradesPublished: state.assignment.assignment.gradesPublished,
|
||||
provisionalGrades: state.grades.provisionalGrades,
|
||||
selectProvisionalGradeStatuses: state.grades.selectProvisionalGradeStatuses,
|
||||
students: state.students.list
|
||||
}
|
||||
}
|
||||
|
@ -88,8 +98,15 @@ function mapDispatchToProps(dispatch) {
|
|||
return {
|
||||
loadStudents() {
|
||||
dispatch(loadStudents())
|
||||
},
|
||||
|
||||
selectGrade(gradeInfo) {
|
||||
dispatch(selectProvisionalGrade(gradeInfo))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Layout)
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(Layout)
|
||||
|
|
|
@ -16,6 +16,8 @@
|
|||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import I18n from 'i18n!assignment_grade_summary'
|
||||
|
||||
function normalizeGraders() {
|
||||
const graders = ENV.GRADERS.map(grader => ({
|
||||
graderId: grader.user_id || grader.anonymous_id,
|
||||
|
@ -25,6 +27,13 @@ function normalizeGraders() {
|
|||
|
||||
graders.sort((a, b) => (a.graderId < b.graderId ? -1 : 1))
|
||||
|
||||
graders.forEach((grader, index) => {
|
||||
/* eslint-disable no-param-reassign */
|
||||
grader.graderName =
|
||||
grader.graderName || I18n.t('Grader %{graderNumber}', {graderNumber: I18n.n(index + 1)})
|
||||
/* eslint-enable no-param-reassign */
|
||||
})
|
||||
|
||||
return graders
|
||||
}
|
||||
|
||||
|
|
|
@ -16,8 +16,40 @@
|
|||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as GradesApi from './GradesApi'
|
||||
|
||||
export const ADD_PROVISIONAL_GRADES = 'ADD_PROVISIONAL_GRADES'
|
||||
export const FAILURE = 'FAILURE'
|
||||
export const SET_SELECTED_PROVISIONAL_GRADE = 'SET_SELECTED_PROVISIONAL_GRADE'
|
||||
export const SET_SELECT_PROVISIONAL_GRADE_STATUS = 'SET_SELECT_PROVISIONAL_GRADE_STATUS'
|
||||
export const STARTED = 'STARTED'
|
||||
export const SUCCESS = 'SUCCESS'
|
||||
|
||||
export function addProvisionalGrades(provisionalGrades) {
|
||||
return {type: ADD_PROVISIONAL_GRADES, payload: {provisionalGrades}}
|
||||
}
|
||||
|
||||
export function setSelectProvisionalGradeStatus(gradeInfo, status) {
|
||||
return {type: SET_SELECT_PROVISIONAL_GRADE_STATUS, status, payload: {gradeInfo, status}}
|
||||
}
|
||||
|
||||
export function setSelectedProvisionalGrade(gradeInfo) {
|
||||
return {type: SET_SELECTED_PROVISIONAL_GRADE, payload: {gradeInfo}}
|
||||
}
|
||||
|
||||
export function selectProvisionalGrade(gradeInfo) {
|
||||
return function(dispatch, getState) {
|
||||
const {assignment} = getState().assignment
|
||||
|
||||
dispatch(setSelectProvisionalGradeStatus(gradeInfo, STARTED))
|
||||
|
||||
GradesApi.selectProvisionalGrade(assignment.courseId, assignment.id, gradeInfo.id)
|
||||
.then(() => {
|
||||
dispatch(setSelectedProvisionalGrade(gradeInfo))
|
||||
dispatch(setSelectProvisionalGradeStatus(gradeInfo, SUCCESS))
|
||||
})
|
||||
.catch(() => {
|
||||
dispatch(setSelectProvisionalGradeStatus(gradeInfo, FAILURE))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright (C) 2018 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export function selectProvisionalGrade(courseId, assignmentId, provisionalGradeId) {
|
||||
const url = `/api/v1/courses/${courseId}/assignments/${assignmentId}/provisional_grades/${provisionalGradeId}/select`
|
||||
|
||||
return axios.put(url)
|
||||
}
|
|
@ -17,7 +17,11 @@
|
|||
*/
|
||||
|
||||
import buildReducer from '../buildReducer'
|
||||
import {ADD_PROVISIONAL_GRADES} from './GradeActions'
|
||||
import {
|
||||
ADD_PROVISIONAL_GRADES,
|
||||
SET_SELECTED_PROVISIONAL_GRADE,
|
||||
SET_SELECT_PROVISIONAL_GRADE_STATUS
|
||||
} from './GradeActions'
|
||||
|
||||
function addProvisionalGrades(state, grades) {
|
||||
const provisionalGrades = {...state.provisionalGrades}
|
||||
|
@ -28,11 +32,37 @@ function addProvisionalGrades(state, grades) {
|
|||
return {...state, provisionalGrades}
|
||||
}
|
||||
|
||||
function setSelectedProvisionalGrade(state, gradeInfo) {
|
||||
const provisionalGrades = {...state.provisionalGrades}
|
||||
const studentGrades = {...provisionalGrades[gradeInfo.studentId]}
|
||||
Object.keys(studentGrades).forEach(graderId => {
|
||||
if (studentGrades[graderId].selected) {
|
||||
studentGrades[graderId] = {...studentGrades[graderId], selected: false}
|
||||
}
|
||||
})
|
||||
studentGrades[gradeInfo.graderId] = {...gradeInfo, selected: true}
|
||||
provisionalGrades[gradeInfo.studentId] = studentGrades
|
||||
return {...state, provisionalGrades}
|
||||
}
|
||||
|
||||
function setSelectProvisionalGradeStatus(state, gradeInfo, status) {
|
||||
const selectProvisionalGradeStatuses = {...state.selectProvisionalGradeStatuses}
|
||||
selectProvisionalGradeStatuses[gradeInfo.studentId] = status
|
||||
return {...state, selectProvisionalGradeStatuses}
|
||||
}
|
||||
|
||||
const handlers = {}
|
||||
|
||||
handlers[ADD_PROVISIONAL_GRADES] = (state, action) =>
|
||||
addProvisionalGrades(state, action.payload.provisionalGrades)
|
||||
handlers[ADD_PROVISIONAL_GRADES] = (state, {payload}) =>
|
||||
addProvisionalGrades(state, payload.provisionalGrades)
|
||||
|
||||
handlers[SET_SELECTED_PROVISIONAL_GRADE] = (state, {payload}) =>
|
||||
setSelectedProvisionalGrade(state, payload.gradeInfo)
|
||||
|
||||
handlers[SET_SELECT_PROVISIONAL_GRADE_STATUS] = (state, {payload}) =>
|
||||
setSelectProvisionalGradeStatus(state, payload.gradeInfo, payload.status)
|
||||
|
||||
export default buildReducer(handlers, {
|
||||
provisionalGrades: {}
|
||||
provisionalGrades: {},
|
||||
selectProvisionalGradeStatuses: {}
|
||||
})
|
||||
|
|
|
@ -21,6 +21,7 @@
|
|||
$headerHeight: 50px;
|
||||
$rowHeight: 50px;
|
||||
$graderColumnWidth: 156px;
|
||||
$finalGradeColumnWidth: 176px;
|
||||
$studentColumnWidth: 200px;
|
||||
$headerBorderColor: rgb(199, 205, 209);
|
||||
|
||||
|
@ -93,7 +94,8 @@ $headerBorderColor: rgb(199, 205, 209);
|
|||
line-height: $headerHeight;
|
||||
}
|
||||
|
||||
.GradesGrid__GraderHeader {
|
||||
.GradesGrid__GraderHeader,
|
||||
.GradesGrid__FinalGradeHeader {
|
||||
max-width: $graderColumnWidth;
|
||||
min-width: $graderColumnWidth;
|
||||
overflow: hidden;
|
||||
|
@ -102,6 +104,15 @@ $headerBorderColor: rgb(199, 205, 209);
|
|||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.GradesGrid__GraderHeader {
|
||||
width: $graderColumnWidth;
|
||||
}
|
||||
|
||||
.GradesGrid__FinalGradeHeader {
|
||||
max-width: $finalGradeColumnWidth;
|
||||
min-width: $finalGradeColumnWidth;
|
||||
}
|
||||
|
||||
.GradesGrid__StudentColumnHeader {
|
||||
max-width: $studentColumnWidth;
|
||||
min-width: $studentColumnWidth;
|
||||
|
@ -124,8 +135,14 @@ $headerBorderColor: rgb(199, 205, 209);
|
|||
|
||||
.GradesGrid__ProvisionalGradeCell {
|
||||
max-width: $graderColumnWidth;
|
||||
padding: 0 $ic-sp;
|
||||
padding: 0;
|
||||
text-align: direction(left);
|
||||
}
|
||||
|
||||
.GradesGrid__FinalGradeCell {
|
||||
max-width: $finalGradeColumnWidth;
|
||||
padding: 0 0 0 $ic-sp;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* End GradesGrid */
|
||||
|
|
|
@ -22,6 +22,7 @@ import {Provider} from 'react-redux'
|
|||
|
||||
import * as FlashAlert from 'jsx/shared/FlashAlert'
|
||||
import * as AssignmentActions from 'jsx/assignments/GradeSummary/assignment/AssignmentActions'
|
||||
import * as GradeActions from 'jsx/assignments/GradeSummary/grades/GradeActions'
|
||||
import * as StudentActions from 'jsx/assignments/GradeSummary/students/StudentActions'
|
||||
import FlashMessageHolder from 'jsx/assignments/GradeSummary/components/FlashMessageHolder'
|
||||
import configureStore from 'jsx/assignments/GradeSummary/configureStore'
|
||||
|
@ -81,6 +82,64 @@ QUnit.module('GradeSummary FlashMessageHolder', suiteHooks => {
|
|||
})
|
||||
})
|
||||
|
||||
QUnit.module('when a provisional grade selection succeeds', hooks => {
|
||||
hooks.beforeEach(() => {
|
||||
mountComponent()
|
||||
const gradeInfo = {
|
||||
grade: 'A',
|
||||
graderId: '1101',
|
||||
id: '4601',
|
||||
score: 10,
|
||||
selected: false,
|
||||
studentId: '1111'
|
||||
}
|
||||
store.dispatch(GradeActions.setSelectProvisionalGradeStatus(gradeInfo, GradeActions.SUCCESS))
|
||||
})
|
||||
|
||||
test('displays a flash alert', () => {
|
||||
strictEqual(FlashAlert.showFlashAlert.callCount, 1)
|
||||
})
|
||||
|
||||
test('uses the success type', () => {
|
||||
const {type} = FlashAlert.showFlashAlert.lastCall.args[0]
|
||||
equal(type, 'success')
|
||||
})
|
||||
|
||||
test('includes a "Grade saved" message', () => {
|
||||
const {message} = FlashAlert.showFlashAlert.lastCall.args[0]
|
||||
equal(message, 'Grade saved.')
|
||||
})
|
||||
})
|
||||
|
||||
QUnit.module('when a provisional grade selection fails', hooks => {
|
||||
hooks.beforeEach(() => {
|
||||
mountComponent()
|
||||
const gradeInfo = {
|
||||
grade: 'A',
|
||||
graderId: '1101',
|
||||
id: '4601',
|
||||
score: 10,
|
||||
selected: false,
|
||||
studentId: '1111'
|
||||
}
|
||||
store.dispatch(GradeActions.setSelectProvisionalGradeStatus(gradeInfo, GradeActions.FAILURE))
|
||||
})
|
||||
|
||||
test('displays a flash alert', () => {
|
||||
strictEqual(FlashAlert.showFlashAlert.callCount, 1)
|
||||
})
|
||||
|
||||
test('uses the error type', () => {
|
||||
const {type} = FlashAlert.showFlashAlert.lastCall.args[0]
|
||||
equal(type, 'error')
|
||||
})
|
||||
|
||||
test('includes a message about saving the grade', () => {
|
||||
const {message} = FlashAlert.showFlashAlert.lastCall.args[0]
|
||||
equal(message, 'There was a problem saving the grade.')
|
||||
})
|
||||
})
|
||||
|
||||
test('does not display a flash alert when publishing grades starts', () => {
|
||||
mountComponent()
|
||||
store.dispatch(AssignmentActions.setPublishGradesStatus(AssignmentActions.STARTED))
|
||||
|
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* Copyright (C) 2018 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import {mount} from 'enzyme'
|
||||
|
||||
import GradeIndicator from 'jsx/assignments/GradeSummary/components/GradesGrid/GradeIndicator'
|
||||
|
||||
QUnit.module('GradeSummary GradeIndicator', suiteHooks => {
|
||||
let $container
|
||||
let props
|
||||
let wrapper
|
||||
|
||||
suiteHooks.beforeEach(() => {
|
||||
$container = document.createElement('div')
|
||||
document.body.appendChild($container)
|
||||
|
||||
props = {
|
||||
gradeInfo: {
|
||||
grade: 'A',
|
||||
graderId: '1101',
|
||||
id: '4601',
|
||||
score: 10,
|
||||
selected: false,
|
||||
studentId: '1111'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
suiteHooks.afterEach(() => {
|
||||
wrapper.unmount()
|
||||
$container.remove()
|
||||
})
|
||||
|
||||
function mountComponent() {
|
||||
wrapper = mount(<GradeIndicator {...props} />, {attachTo: $container})
|
||||
}
|
||||
|
||||
test('displays the score', () => {
|
||||
mountComponent()
|
||||
strictEqual(wrapper.text(), '10')
|
||||
})
|
||||
|
||||
test('displays a zero score', () => {
|
||||
props.gradeInfo.score = 0
|
||||
mountComponent()
|
||||
strictEqual(wrapper.text(), '0')
|
||||
})
|
||||
|
||||
test('displays "–" (en dash) when there is no grade', () => {
|
||||
delete props.gradeInfo
|
||||
mountComponent()
|
||||
strictEqual(wrapper.text(), '–')
|
||||
})
|
||||
|
||||
test('changes the background color when the grade is selected', () => {
|
||||
mountComponent()
|
||||
const style = window.getComputedStyle(wrapper.getDOMNode())
|
||||
const backgroundColorBefore = style.backgroundColor
|
||||
wrapper.setProps({gradeInfo: {...props.gradeInfo, selected: true}})
|
||||
notEqual(style.backgroundColor, backgroundColorBefore)
|
||||
})
|
||||
|
||||
test('changes the text color when the grade is selected', () => {
|
||||
mountComponent()
|
||||
const style = window.getComputedStyle(wrapper.childAt(0).getDOMNode())
|
||||
const colorBefore = style.color
|
||||
wrapper.setProps({gradeInfo: {...props.gradeInfo, selected: true}})
|
||||
notEqual(style.color, colorBefore)
|
||||
})
|
||||
})
|
|
@ -0,0 +1,257 @@
|
|||
/*
|
||||
* Copyright (C) 2018 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import {mount} from 'enzyme'
|
||||
|
||||
import GradeSelect from 'jsx/assignments/GradeSummary/components/GradesGrid/GradeSelect'
|
||||
import {FAILURE, STARTED, SUCCESS} from 'jsx/assignments/GradeSummary/grades/GradeActions'
|
||||
|
||||
QUnit.module('GradeSummary GradeSelect', suiteHooks => {
|
||||
let props
|
||||
let qunitTimeout
|
||||
let resolveOpenCloseState
|
||||
let selectedGrade
|
||||
let wrapper
|
||||
|
||||
suiteHooks.beforeEach(() => {
|
||||
qunitTimeout = QUnit.config.testTimeout
|
||||
QUnit.config.testTimeout = 500 // prevent accidental unresolved async
|
||||
|
||||
selectedGrade = null
|
||||
|
||||
props = {
|
||||
graders: [
|
||||
{graderId: '1101', graderName: 'Miss Frizzle'},
|
||||
{graderId: '1102', graderName: 'Mr. Keating'}
|
||||
],
|
||||
grades: {
|
||||
1101: {
|
||||
grade: 'A',
|
||||
graderId: '1101',
|
||||
id: '4601',
|
||||
score: 10,
|
||||
selected: false,
|
||||
studentId: '1111'
|
||||
},
|
||||
1102: {
|
||||
grade: 'B',
|
||||
graderId: '1102',
|
||||
id: '4602',
|
||||
score: 8.9,
|
||||
selected: true,
|
||||
studentId: '1111'
|
||||
}
|
||||
},
|
||||
onClose() {
|
||||
resolveOpenCloseState()
|
||||
},
|
||||
onOpen() {
|
||||
resolveOpenCloseState()
|
||||
},
|
||||
onSelect(gradeInfo) {
|
||||
selectedGrade = gradeInfo
|
||||
},
|
||||
selectProvisionalGradeStatus: null,
|
||||
studentName: 'Adam Jones'
|
||||
}
|
||||
})
|
||||
|
||||
suiteHooks.afterEach(() => {
|
||||
wrapper.unmount()
|
||||
QUnit.config.testTimeout = qunitTimeout
|
||||
})
|
||||
|
||||
function mountComponent() {
|
||||
wrapper = mount(<GradeSelect {...props} />)
|
||||
}
|
||||
|
||||
function getOptionList() {
|
||||
const controlledContentId = wrapper.find('input').prop('aria-controls')
|
||||
return document.getElementById(controlledContentId)
|
||||
}
|
||||
|
||||
function getOptions() {
|
||||
const $list = getOptionList()
|
||||
const $items = $list.querySelectorAll('li[role="option"]')
|
||||
return Array.from($items)
|
||||
}
|
||||
|
||||
function getOptionLabels() {
|
||||
return getOptions().map($option => $option.textContent.trim())
|
||||
}
|
||||
|
||||
function openSelect() {
|
||||
return new Promise(resolve => {
|
||||
resolveOpenCloseState = resolve
|
||||
wrapper.find('input').simulate('click')
|
||||
})
|
||||
}
|
||||
|
||||
function selectOption(optionLabel) {
|
||||
return openSelect().then(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
resolveOpenCloseState = resolve
|
||||
const $option = getOptions().find($el => $el.textContent.trim() === optionLabel)
|
||||
$option.click()
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function getTextInputValue() {
|
||||
return wrapper.find('input').getDOMNode().value
|
||||
}
|
||||
|
||||
function labelForGrader(graderId) {
|
||||
const gradeInfo = props.grades[graderId]
|
||||
const grader = props.graders.find(g => g.graderId === graderId)
|
||||
return `${gradeInfo.score} (${grader.graderName})`
|
||||
}
|
||||
|
||||
test('renders a text input', () => {
|
||||
mountComponent()
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
strictEqual(input.length, 1)
|
||||
})
|
||||
|
||||
test('uses the student name for a label', () => {
|
||||
mountComponent()
|
||||
const label = wrapper.find('label')
|
||||
strictEqual(label.text(), 'Grade for Adam Jones')
|
||||
})
|
||||
|
||||
test('includes an option for each grader who graded', () => {
|
||||
mountComponent()
|
||||
strictEqual(getOptions().length, 2)
|
||||
})
|
||||
|
||||
test('displays the grade and grader name as option labels', () => {
|
||||
mountComponent()
|
||||
deepEqual(getOptionLabels(), [labelForGrader('1101'), labelForGrader('1102')])
|
||||
})
|
||||
|
||||
test('sets as the input value the selected provisional grade', () => {
|
||||
mountComponent()
|
||||
equal(getTextInputValue(), labelForGrader('1102'))
|
||||
})
|
||||
|
||||
test('calls the onSelect prop when an option is clicked', async () => {
|
||||
props.onSelect = sinon.spy()
|
||||
mountComponent()
|
||||
await selectOption(labelForGrader('1101'))
|
||||
strictEqual(props.onSelect.callCount, 1)
|
||||
})
|
||||
|
||||
test('includes the related grade info when calling onSelect', async () => {
|
||||
mountComponent()
|
||||
await selectOption(labelForGrader('1101'))
|
||||
deepEqual(selectedGrade, props.grades[1101])
|
||||
})
|
||||
|
||||
test('does not call the onSelect prop when the option for the selected grade is clicked', async () => {
|
||||
props.onSelect = sinon.spy()
|
||||
mountComponent()
|
||||
await selectOption(labelForGrader('1102'))
|
||||
strictEqual(props.onSelect.callCount, 0)
|
||||
})
|
||||
|
||||
test('sets the input to read-only when not given an onSelect prop', () => {
|
||||
props.onSelect = null
|
||||
mountComponent()
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
strictEqual(input.prop('aria-readonly'), true)
|
||||
})
|
||||
|
||||
test('has no effect when an option is clicked and not given an onSelect prop', async () => {
|
||||
props.onSelect = null
|
||||
mountComponent()
|
||||
await selectOption(labelForGrader('1102'))
|
||||
ok('component gracefully ignores the event')
|
||||
})
|
||||
|
||||
test('sets the input to read-only when grade selection is pending', () => {
|
||||
props.selectProvisionalGradeStatus = STARTED
|
||||
mountComponent()
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
strictEqual(input.prop('aria-readonly'), true)
|
||||
})
|
||||
|
||||
test('enables the input when grade selection was successful', () => {
|
||||
props.selectProvisionalGradeStatus = SUCCESS
|
||||
mountComponent()
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
strictEqual(input.prop('aria-disabled'), null)
|
||||
})
|
||||
|
||||
test('enables the input when grade selection has failed', () => {
|
||||
props.selectProvisionalGradeStatus = FAILURE
|
||||
mountComponent()
|
||||
const input = wrapper.find('input[type="text"]')
|
||||
strictEqual(input.prop('aria-disabled'), null)
|
||||
})
|
||||
|
||||
QUnit.module('when no grade has been selected', hooks => {
|
||||
hooks.beforeEach(() => {
|
||||
props.grades[1102].selected = false
|
||||
})
|
||||
|
||||
test('includes an option for "no selection"', () => {
|
||||
mountComponent()
|
||||
strictEqual(getOptionLabels()[0], '–')
|
||||
})
|
||||
|
||||
test('includes an option for each grader who graded', () => {
|
||||
mountComponent()
|
||||
strictEqual(getOptions().length, 3) // two graders plus "no selection" option
|
||||
})
|
||||
|
||||
test('set the input value to "–" (en dash)', () => {
|
||||
mountComponent()
|
||||
strictEqual(getTextInputValue(), '–')
|
||||
})
|
||||
})
|
||||
|
||||
QUnit.module('when the component instance is reused for another student', hooks => {
|
||||
/*
|
||||
* The purpose of this context is to help ensure that component instance
|
||||
* reuse does not result in state-based bugs. When updating input components
|
||||
* like Select, updating internal state can easily lead to mismatches
|
||||
* between old state and new props.
|
||||
*/
|
||||
|
||||
hooks.beforeEach(() => {
|
||||
mountComponent()
|
||||
props = JSON.parse(JSON.stringify(props))
|
||||
props.studentName = 'Betty Ford'
|
||||
props.grades[1101].studentId = '1112'
|
||||
props.grades[1102].studentId = '1112'
|
||||
})
|
||||
|
||||
test('includes an option for "no selection" when the student has no selected grade', () => {
|
||||
props.grades[1102].selected = false
|
||||
wrapper.setProps(props)
|
||||
strictEqual(getOptions().length, 3) // two graders plus "no selection" option
|
||||
})
|
||||
|
||||
test('excludes the option for "no selection" when the student has a selected grade', () => {
|
||||
wrapper.setProps(props)
|
||||
strictEqual(getOptions().length, 2) // only the two graders
|
||||
})
|
||||
})
|
||||
})
|
|
@ -20,6 +20,7 @@ import React from 'react'
|
|||
import {mount} from 'enzyme'
|
||||
|
||||
import GridRow from 'jsx/assignments/GradeSummary/components/GradesGrid/GridRow'
|
||||
import {STARTED} from 'jsx/assignments/GradeSummary/grades/GradeActions'
|
||||
|
||||
QUnit.module('GradeSummary GridRow', suiteHooks => {
|
||||
let props
|
||||
|
@ -49,10 +50,12 @@ QUnit.module('GradeSummary GridRow', suiteHooks => {
|
|||
studentId: '1111'
|
||||
}
|
||||
},
|
||||
onGradeSelect() {},
|
||||
row: {
|
||||
studentId: '1111',
|
||||
studentName: 'Adam Jones'
|
||||
}
|
||||
},
|
||||
selectProvisionalGradeStatus: STARTED
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -115,4 +118,39 @@ QUnit.module('GradeSummary GridRow', suiteHooks => {
|
|||
const cell = wrapper.find('td.grader_1101')
|
||||
equal(cell.text(), '–')
|
||||
})
|
||||
|
||||
QUnit.module('GradeSelect', () => {
|
||||
test('receives the graders prop', () => {
|
||||
mountComponent()
|
||||
const gradeSelect = wrapper.find('GradeSelect')
|
||||
strictEqual(gradeSelect.prop('graders'), props.graders)
|
||||
})
|
||||
|
||||
test('receives the grades prop', () => {
|
||||
mountComponent()
|
||||
const gradeSelect = wrapper.find('GradeSelect')
|
||||
strictEqual(gradeSelect.prop('grades'), props.grades)
|
||||
})
|
||||
|
||||
test('receives the onGradeSelect prop as onSelect', () => {
|
||||
mountComponent()
|
||||
const gradeSelect = wrapper.find('GradeSelect')
|
||||
strictEqual(gradeSelect.prop('onSelect'), props.onGradeSelect)
|
||||
})
|
||||
|
||||
test('receives the selectProvisionalGradeStatus prop', () => {
|
||||
mountComponent()
|
||||
const gradeSelect = wrapper.find('GradeSelect')
|
||||
strictEqual(
|
||||
gradeSelect.prop('selectProvisionalGradeStatus'),
|
||||
props.selectProvisionalGradeStatus
|
||||
)
|
||||
})
|
||||
|
||||
test('receives the studentName prop from the row', () => {
|
||||
mountComponent()
|
||||
const gradeSelect = wrapper.find('GradeSelect')
|
||||
strictEqual(gradeSelect.prop('studentName'), 'Adam Jones')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -20,6 +20,8 @@ import React from 'react'
|
|||
import {mount} from 'enzyme'
|
||||
|
||||
import Grid from 'jsx/assignments/GradeSummary/components/GradesGrid/Grid'
|
||||
import GridRow from 'jsx/assignments/GradeSummary/components/GradesGrid/GridRow'
|
||||
import {STARTED, SUCCESS} from 'jsx/assignments/GradeSummary/grades/GradeActions'
|
||||
|
||||
QUnit.module('GradeSummary Grid', suiteHooks => {
|
||||
let props
|
||||
|
@ -74,12 +76,17 @@ QUnit.module('GradeSummary Grid', suiteHooks => {
|
|||
},
|
||||
|
||||
horizontalScrollRef: sinon.spy(),
|
||||
onGradeSelect() {},
|
||||
rows: [
|
||||
{studentId: '1111', studentName: 'Adam Jones'},
|
||||
{studentId: '1112', studentName: 'Betty Ford'},
|
||||
{studentId: '1113', studentName: 'Charlie Xi'},
|
||||
{studentId: '1114', studentName: 'Dana Smith'}
|
||||
]
|
||||
],
|
||||
selectProvisionalGradeStatuses: {
|
||||
1111: SUCCESS,
|
||||
1112: STARTED
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -91,46 +98,61 @@ QUnit.module('GradeSummary Grid', suiteHooks => {
|
|||
wrapper = mount(<Grid {...props} />)
|
||||
}
|
||||
|
||||
test('includes a column header for the student name column', () => {
|
||||
mountComponent()
|
||||
strictEqual(wrapper.find('th.GradesGrid__StudentColumnHeader').length, 1)
|
||||
})
|
||||
|
||||
test('includes a column header for each grader', () => {
|
||||
mountComponent()
|
||||
strictEqual(wrapper.find('th.GradesGrid__GraderHeader').length, 2)
|
||||
})
|
||||
|
||||
test('includes a column header for the final grade column', () => {
|
||||
mountComponent()
|
||||
strictEqual(wrapper.find('th.GradesGrid__FinalGradeHeader').length, 1)
|
||||
})
|
||||
|
||||
test('displays the grader names in the column headers', () => {
|
||||
mountComponent()
|
||||
const headers = wrapper.find('th.GradesGrid__GraderHeader')
|
||||
deepEqual(headers.map(header => header.text()), ['Miss Frizzle', 'Mr. Keating'])
|
||||
})
|
||||
|
||||
test('enumerates graders for names when graders are anonymous', () => {
|
||||
props.graders[0].graderName = null
|
||||
props.graders[1].graderName = null
|
||||
mountComponent()
|
||||
const headers = wrapper.find('th.GradesGrid__GraderHeader')
|
||||
deepEqual(headers.map(header => header.text()), ['Grader 1', 'Grader 2'])
|
||||
})
|
||||
|
||||
test('includes a GridRow for each student', () => {
|
||||
mountComponent()
|
||||
strictEqual(wrapper.find('GridRow').length, 4)
|
||||
strictEqual(wrapper.find(GridRow).length, 4)
|
||||
})
|
||||
|
||||
test('sends graders to each GridRow', () => {
|
||||
mountComponent()
|
||||
wrapper.find('GridRow').forEach(gridRow => {
|
||||
wrapper.find(GridRow).forEach(gridRow => {
|
||||
strictEqual(gridRow.prop('graders'), props.graders)
|
||||
})
|
||||
})
|
||||
|
||||
test('sends onGradeSelect to each GridRow', () => {
|
||||
mountComponent()
|
||||
wrapper.find(GridRow).forEach(gridRow => {
|
||||
strictEqual(gridRow.prop('onGradeSelect'), props.onGradeSelect)
|
||||
})
|
||||
})
|
||||
|
||||
test('sends student-specific grades to each GridRow', () => {
|
||||
mountComponent()
|
||||
const gridRow = wrapper.find('GridRow').at(1)
|
||||
const gridRow = wrapper.find(GridRow).at(1)
|
||||
strictEqual(gridRow.prop('grades'), props.grades[1112])
|
||||
})
|
||||
|
||||
test('sends student-specific select provisional grade statuses to each GridRow', () => {
|
||||
mountComponent()
|
||||
const gridRow = wrapper.find(GridRow).at(1)
|
||||
strictEqual(gridRow.prop('selectProvisionalGradeStatus'), STARTED)
|
||||
})
|
||||
|
||||
test('sends the related row to each GridRow', () => {
|
||||
mountComponent()
|
||||
const gridRow = wrapper.find('GridRow').at(1)
|
||||
const gridRow = wrapper.find(GridRow).at(1)
|
||||
strictEqual(gridRow.prop('row'), props.rows[1])
|
||||
})
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@
|
|||
import React from 'react'
|
||||
import {mount} from 'enzyme'
|
||||
|
||||
import Grid from 'jsx/assignments/GradeSummary/components/GradesGrid/Grid'
|
||||
import GradesGrid from 'jsx/assignments/GradeSummary/components/GradesGrid'
|
||||
|
||||
QUnit.module('GradeSummary GradesGrid', suiteHooks => {
|
||||
|
@ -71,6 +72,8 @@ QUnit.module('GradeSummary GradesGrid', suiteHooks => {
|
|||
}
|
||||
}
|
||||
},
|
||||
onGradeSelect() {},
|
||||
selectProvisionalGradeStatuses: {},
|
||||
students: [
|
||||
{id: '1111', displayName: 'Adam Jones'},
|
||||
{id: '1112', displayName: 'Betty Ford'},
|
||||
|
@ -108,18 +111,23 @@ QUnit.module('GradeSummary GradesGrid', suiteHooks => {
|
|||
deepEqual(getGraderNames(), ['Miss Frizzle', 'Mr. Keating'])
|
||||
})
|
||||
|
||||
test('enumerates graders for names when graders are anonymous', () => {
|
||||
props.graders[0].graderName = null
|
||||
props.graders[1].graderName = null
|
||||
mountComponent()
|
||||
deepEqual(getGraderNames(), ['Grader 1', 'Grader 2'])
|
||||
})
|
||||
|
||||
test('includes a row for each student', () => {
|
||||
mountComponent()
|
||||
strictEqual(wrapper.find('tr.GradesGrid__BodyRow').length, 4)
|
||||
})
|
||||
|
||||
test('sends onGradeSelect to the Grid', () => {
|
||||
mountComponent()
|
||||
const grid = wrapper.find(Grid)
|
||||
strictEqual(grid.prop('onGradeSelect'), props.onGradeSelect)
|
||||
})
|
||||
|
||||
test('sends selectProvisionalGradeStatuses to the Grid', () => {
|
||||
mountComponent()
|
||||
const grid = wrapper.find(Grid)
|
||||
strictEqual(grid.prop('selectProvisionalGradeStatuses'), props.selectProvisionalGradeStatuses)
|
||||
})
|
||||
|
||||
test('adds rows as students are added', () => {
|
||||
const {students} = props
|
||||
props.students = students.slice(0, 2)
|
||||
|
|
|
@ -20,6 +20,8 @@ import React from 'react'
|
|||
import {mount} from 'enzyme'
|
||||
import {Provider} from 'react-redux'
|
||||
|
||||
import * as AssignmentActions from 'jsx/assignments/GradeSummary/assignment/AssignmentActions'
|
||||
import * as GradeActions from 'jsx/assignments/GradeSummary/grades/GradeActions'
|
||||
import * as StudentActions from 'jsx/assignments/GradeSummary/students/StudentActions'
|
||||
import Layout from 'jsx/assignments/GradeSummary/components/Layout'
|
||||
import configureStore from 'jsx/assignments/GradeSummary/configureStore'
|
||||
|
@ -47,9 +49,13 @@ QUnit.module('GradeSummary Layout', suiteHooks => {
|
|||
sinon
|
||||
.stub(StudentActions, 'loadStudents')
|
||||
.returns(StudentActions.setLoadStudentsStatus(StudentActions.STARTED))
|
||||
sinon
|
||||
.stub(GradeActions, 'selectProvisionalGrade')
|
||||
.callsFake(gradeInfo => GradeActions.setSelectedProvisionalGrade(gradeInfo))
|
||||
})
|
||||
|
||||
suiteHooks.afterEach(() => {
|
||||
GradeActions.selectProvisionalGrade.restore()
|
||||
StudentActions.loadStudents.restore()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
@ -105,4 +111,39 @@ QUnit.module('GradeSummary Layout', suiteHooks => {
|
|||
strictEqual(wrapper.find('Spinner').length, 0)
|
||||
})
|
||||
})
|
||||
|
||||
QUnit.module('GradesGrid', hooks => {
|
||||
let grades
|
||||
|
||||
hooks.beforeEach(() => {
|
||||
mountComponent()
|
||||
const students = [
|
||||
{id: '1111', displayName: 'Adam Jones'},
|
||||
{id: '1112', displayName: 'Betty Ford'}
|
||||
]
|
||||
store.dispatch(StudentActions.addStudents(students))
|
||||
grades = [
|
||||
{grade: 'A', graderId: '1101', id: '4601', score: 10, selected: false, studentId: '1111'}
|
||||
]
|
||||
store.dispatch(GradeActions.addProvisionalGrades(grades))
|
||||
})
|
||||
|
||||
test('onGradeSelect selects a provisional grade when grades have not been published', () => {
|
||||
const onGradeSelect = wrapper.find('GradesGrid').prop('onGradeSelect')
|
||||
onGradeSelect(grades[0])
|
||||
const gradeInfo = store.getState().grades.provisionalGrades[1111][1101]
|
||||
strictEqual(gradeInfo.selected, true)
|
||||
})
|
||||
|
||||
test('is null when grades have not been published', () => {
|
||||
store.dispatch(AssignmentActions.updateAssignment({gradesPublished: true}))
|
||||
const onGradeSelect = wrapper.find('GradesGrid').prop('onGradeSelect')
|
||||
strictEqual(onGradeSelect, null)
|
||||
})
|
||||
|
||||
test('receives the selectProvisionalGradeStatuses from state', () => {
|
||||
const statuses = wrapper.find('GradesGrid').prop('selectProvisionalGradeStatuses')
|
||||
strictEqual(statuses, store.getState().grades.selectProvisionalGradeStatuses)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -127,9 +127,9 @@ QUnit.module('GradeSummary getEnv()', suiteHooks => {
|
|||
deepEqual(graderIds, ['abcde', 'b01ng', 'h2asd'])
|
||||
})
|
||||
|
||||
test('sets .graderName to null', () => {
|
||||
test('assigns enumerated names', () => {
|
||||
const graderNames = getEnv().graders.map(grader => grader.graderName)
|
||||
deepEqual(graderNames, [null, null, null])
|
||||
deepEqual(graderNames, ['Grader 1', 'Grader 2', 'Grader 3'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
*/
|
||||
|
||||
import * as GradeActions from 'jsx/assignments/GradeSummary/grades/GradeActions'
|
||||
import * as GradesApi from 'jsx/assignments/GradeSummary/grades/GradesApi'
|
||||
import configureStore from 'jsx/assignments/GradeSummary/configureStore'
|
||||
|
||||
QUnit.module('GradeSummary GradeActions', suiteHooks => {
|
||||
|
@ -56,4 +57,96 @@ QUnit.module('GradeSummary GradeActions', suiteHooks => {
|
|||
deepEqual(grades[1112][1102], provisionalGrades[1])
|
||||
})
|
||||
})
|
||||
|
||||
QUnit.module('.selectProvisionalGrade()', hooks => {
|
||||
let args
|
||||
let rejectPromise
|
||||
let resolvePromise
|
||||
|
||||
hooks.beforeEach(() => {
|
||||
const fakePromise = {
|
||||
then(callback) {
|
||||
resolvePromise = callback
|
||||
return fakePromise
|
||||
},
|
||||
|
||||
catch(callback) {
|
||||
rejectPromise = callback
|
||||
}
|
||||
}
|
||||
|
||||
sinon
|
||||
.stub(GradesApi, 'selectProvisionalGrade')
|
||||
.callsFake((courseId, assignmentId, provisionalGradeId) => {
|
||||
args = {courseId, assignmentId, provisionalGradeId}
|
||||
return fakePromise
|
||||
})
|
||||
|
||||
const provisionalGrades = [
|
||||
{
|
||||
grade: 'A',
|
||||
graderId: '1101',
|
||||
id: '4601',
|
||||
score: 10,
|
||||
selected: false,
|
||||
studentId: '1111'
|
||||
},
|
||||
{
|
||||
grade: 'B',
|
||||
graderId: '1102',
|
||||
id: '4602',
|
||||
score: 9,
|
||||
selected: false,
|
||||
studentId: '1111'
|
||||
}
|
||||
]
|
||||
|
||||
store.dispatch(GradeActions.addProvisionalGrades(provisionalGrades))
|
||||
store.dispatch(GradeActions.selectProvisionalGrade(provisionalGrades[0]))
|
||||
})
|
||||
|
||||
hooks.afterEach(() => {
|
||||
args = null
|
||||
GradesApi.selectProvisionalGrade.restore()
|
||||
})
|
||||
|
||||
test('sets the "set selected provisional grade" status to "started"', () => {
|
||||
const {selectProvisionalGradeStatuses} = store.getState().grades
|
||||
equal(selectProvisionalGradeStatuses[1111], GradeActions.STARTED)
|
||||
})
|
||||
|
||||
test('selects the provisional grade through the api', () => {
|
||||
strictEqual(GradesApi.selectProvisionalGrade.callCount, 1)
|
||||
})
|
||||
|
||||
test('includes the course id when selecting through the api', () => {
|
||||
strictEqual(args.courseId, '1201')
|
||||
})
|
||||
|
||||
test('includes the assignment id when selecting through the api', () => {
|
||||
strictEqual(args.assignmentId, '2301')
|
||||
})
|
||||
|
||||
test('includes the provisional grade id when selecting through the api', () => {
|
||||
strictEqual(args.provisionalGradeId, '4601')
|
||||
})
|
||||
|
||||
test('updates the selected provisional grade in the store when the request succeeds', () => {
|
||||
resolvePromise()
|
||||
const grades = store.getState().grades.provisionalGrades
|
||||
strictEqual(grades[1111][1101].selected, true)
|
||||
})
|
||||
|
||||
test('sets the "set selected provisional grade" status to "success" when the request succeeds', () => {
|
||||
resolvePromise()
|
||||
const {selectProvisionalGradeStatuses} = store.getState().grades
|
||||
equal(selectProvisionalGradeStatuses[1111], GradeActions.SUCCESS)
|
||||
})
|
||||
|
||||
test('sets the "set selected provisional grade" status to "failure" when a failure occurs', () => {
|
||||
rejectPromise(new Error('server error'))
|
||||
const {selectProvisionalGradeStatuses} = store.getState().grades
|
||||
equal(selectProvisionalGradeStatuses[1111], GradeActions.FAILURE)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* Copyright (C) 2018 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as GradesApi from 'jsx/assignments/GradeSummary/grades/GradesApi'
|
||||
import FakeServer, {pathFromRequest} from 'jsx/__tests__/FakeServer'
|
||||
|
||||
QUnit.module('GradeSummary GradesApi', suiteHooks => {
|
||||
let qunitTimeout
|
||||
let server
|
||||
|
||||
suiteHooks.beforeEach(() => {
|
||||
qunitTimeout = QUnit.config.testTimeout
|
||||
QUnit.config.testTimeout = 500 // avoid accidental unresolved async
|
||||
server = new FakeServer()
|
||||
})
|
||||
|
||||
suiteHooks.afterEach(() => {
|
||||
server.teardown()
|
||||
QUnit.config.testTimeout = qunitTimeout
|
||||
})
|
||||
|
||||
QUnit.module('.selectProvisionalGrade()', () => {
|
||||
const url = `/api/v1/courses/1201/assignments/2301/provisional_grades/4601/select`
|
||||
|
||||
test('sends a request to select a provisional grade', async () => {
|
||||
server.for(url).respond({status: 200, body: {}})
|
||||
await GradesApi.selectProvisionalGrade('1201', '2301', '4601')
|
||||
const request = server.receivedRequests[0]
|
||||
equal(pathFromRequest(request), url)
|
||||
})
|
||||
|
||||
test('sends a PUT request', async () => {
|
||||
server.for(url).respond({status: 200, body: {}})
|
||||
await GradesApi.selectProvisionalGrade('1201', '2301', '4601')
|
||||
const request = server.receivedRequests[0]
|
||||
equal(request.method, 'PUT')
|
||||
})
|
||||
|
||||
test('does not catch failures', async () => {
|
||||
server.for(url).respond({status: 500, body: {error: 'server error'}})
|
||||
try {
|
||||
await GradesApi.selectProvisionalGrade('1201', '2301', '4601')
|
||||
} catch (e) {
|
||||
ok(e.message.includes('500'))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
|
@ -89,4 +89,94 @@ QUnit.module('GradeSummary gradesReducer()', suiteHooks => {
|
|||
deepEqual(getProvisionalGrades()[1112][1102], provisionalGrades[1])
|
||||
})
|
||||
})
|
||||
|
||||
QUnit.module('when handling "SET_SELECTED_PROVISIONAL_GRADE"', hooks => {
|
||||
hooks.beforeEach(() => {
|
||||
store.dispatch(GradeActions.addProvisionalGrades(provisionalGrades))
|
||||
})
|
||||
|
||||
test('sets the given provisional grade as selected', () => {
|
||||
store.dispatch(GradeActions.setSelectedProvisionalGrade(provisionalGrades[2]))
|
||||
strictEqual(getProvisionalGrades()[1111][1102].selected, true)
|
||||
})
|
||||
|
||||
test('sets the previously-selected provisional grade as not selected', () => {
|
||||
store.dispatch(GradeActions.setSelectedProvisionalGrade(provisionalGrades[2]))
|
||||
strictEqual(getProvisionalGrades()[1111][1101].selected, false)
|
||||
})
|
||||
|
||||
test('replaces the instance of the given provisional grade', () => {
|
||||
const grade = getProvisionalGrades()[1111][1102]
|
||||
store.dispatch(GradeActions.setSelectedProvisionalGrade(grade))
|
||||
notEqual(getProvisionalGrades()[1111][1102], grade)
|
||||
})
|
||||
|
||||
test('replaces the instance of the de-selected provisional grade', () => {
|
||||
const grade = getProvisionalGrades()[1111][1101]
|
||||
store.dispatch(GradeActions.setSelectedProvisionalGrade(provisionalGrades[2]))
|
||||
notEqual(getProvisionalGrades()[1111][1101], grade)
|
||||
})
|
||||
|
||||
test('does not replace instances of related grades not previously selected', () => {
|
||||
const grade = getProvisionalGrades()[1111][1101]
|
||||
grade.selected = false
|
||||
store.dispatch(GradeActions.setSelectedProvisionalGrade(provisionalGrades[2]))
|
||||
strictEqual(getProvisionalGrades()[1111][1101], grade)
|
||||
})
|
||||
|
||||
test('replaces the instance of student grades collection', () => {
|
||||
const grades = getProvisionalGrades()[1111]
|
||||
store.dispatch(GradeActions.setSelectedProvisionalGrade(provisionalGrades[2]))
|
||||
notEqual(getProvisionalGrades()[1111], grades)
|
||||
})
|
||||
|
||||
test('does not replace instances of unrelated student grades collections', () => {
|
||||
const grades = getProvisionalGrades()[1112]
|
||||
store.dispatch(GradeActions.setSelectedProvisionalGrade(provisionalGrades[2]))
|
||||
strictEqual(getProvisionalGrades()[1112], grades)
|
||||
})
|
||||
|
||||
test('replaces the provisional grades instance', () => {
|
||||
const grades = getProvisionalGrades()
|
||||
store.dispatch(GradeActions.setSelectedProvisionalGrade(provisionalGrades[2]))
|
||||
notEqual(getProvisionalGrades(), grades)
|
||||
})
|
||||
})
|
||||
|
||||
QUnit.module('when handling "SET_SELECT_PROVISIONAL_GRADE_STATUS"', () => {
|
||||
function setSelectProvisionalGradeStatus(gradeInfo, status) {
|
||||
store.dispatch(GradeActions.setSelectProvisionalGradeStatus(gradeInfo, status))
|
||||
}
|
||||
|
||||
function getSelectProvisionalGradeStatus(studentId) {
|
||||
return store.getState().grades.selectProvisionalGradeStatuses[studentId]
|
||||
}
|
||||
|
||||
test('optionally sets the "select provisional grade" status to "failure" for the related student', () => {
|
||||
setSelectProvisionalGradeStatus(provisionalGrades[0], GradeActions.FAILURE)
|
||||
equal(getSelectProvisionalGradeStatus(1111), GradeActions.FAILURE)
|
||||
})
|
||||
|
||||
test('optionally sets the "select provisional grade" status to "started" for the related student', () => {
|
||||
setSelectProvisionalGradeStatus(provisionalGrades[0], GradeActions.STARTED)
|
||||
equal(getSelectProvisionalGradeStatus(1111), GradeActions.STARTED)
|
||||
})
|
||||
|
||||
test('optionally sets the "select provisional grade" status to "success" for the related student', () => {
|
||||
setSelectProvisionalGradeStatus(provisionalGrades[0], GradeActions.SUCCESS)
|
||||
equal(getSelectProvisionalGradeStatus(1111), GradeActions.SUCCESS)
|
||||
})
|
||||
|
||||
test('replaces the previous status for the related student', () => {
|
||||
setSelectProvisionalGradeStatus(provisionalGrades[0], GradeActions.STARTED)
|
||||
setSelectProvisionalGradeStatus(provisionalGrades[0], GradeActions.SUCCESS)
|
||||
equal(getSelectProvisionalGradeStatus(1111), GradeActions.SUCCESS)
|
||||
})
|
||||
|
||||
test('does not affect unrelated students', () => {
|
||||
setSelectProvisionalGradeStatus(provisionalGrades[1], GradeActions.STARTED)
|
||||
setSelectProvisionalGradeStatus(provisionalGrades[0], GradeActions.SUCCESS)
|
||||
equal(getSelectProvisionalGradeStatus(1112), GradeActions.STARTED)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Reference in New Issue