load students and graders into moderation table

closes GRADE-964
closes GRADE-1024
closes GRADE-1059
refs GRADE-877
refs GRADE-1166

test plan:
 A. Setup
    1. Enable Anonymous Moderated Marking
    2. Select or create a course
       a. with Anonymous Marking enabled
       b. with three instructors (A, B, and C)
       c. with 51 or more students
          * this is for multiple pages of students
    3. Select or create an assignment
       a. with Moderated Grading
       b. with Anonymous Grading
       c. with instructor A as the final grader
    4. As each instructor (B and C)
       a. give at least one student a provisional grade
          on each assignment (using SpeedGrader)
       * This SHOULD create a new moderation_grader for each user
         and for each assignment

 B. Verify
    1. Enable grader_names_visible_to_final_grader

       assignment.grader_names_visible_to_final_grader = true
       assignment.save!

    2. Log in or act as instructor A
    3. Visit the moderation page for the assignment
    4. Verify both instructors B an C are named in the table
    5. Verify all students are loaded into the table
    6. Verify the correct grades are displayed in the table
    7. Verify grades as associated with the correct graders
    8. Verify ungraded student/grader pairs show as "–"

 C. Verify with Other Graders Anonymous
    1. Disable grader_names_visible_to_final_grader

       assignment.grader_names_visible_to_final_grader = false
       assignment.save!

    2. Log in or act as instructor A
    3. Visit the moderation page for the assignment
    4. Verify column headers display anonymous grader names

    * Students are not yet anonymized.

Change-Id: I155567cf461380f7018c964e74d775ed182c9954
Reviewed-on: https://gerrit.instructure.com/151269
Tested-by: Jenkins
Reviewed-by: Adrian Packel <apackel@instructure.com>
Reviewed-by: Keith T. Garner <kgarner@instructure.com>
QA-Review: Indira Pai <ipai@instructure.com>
Product-Review: Sidharth Oberoi <soberoi@instructure.com>
This commit is contained in:
Jeremy Neander 2018-05-22 19:52:47 -05:00
parent 31bae72fe9
commit c8229d721e
23 changed files with 1722 additions and 37 deletions

View File

@ -858,7 +858,7 @@ class SubmissionsApiController < ApplicationController
student_scope = student_scope.order(:id)
students = Api.paginate(student_scope, self, api_v1_course_assignment_gradeable_students_url(@context, @assignment))
if (include_pg = includes.include?('provisional_grades'))
return unless authorized_action(@context, @current_user, :moderate_grades)
render_unauthorized_action and return unless @assignment.permits_moderation?(@current_user)
submissions = @assignment.submissions.where(user_id: students).preload(:provisional_grades).index_by(&:user_id)
selections = @assignment.moderated_grading_selections.where(student_id: students).index_by(&:student_id)
end

View File

@ -0,0 +1,82 @@
/*
* 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, shape, string} from 'prop-types'
import Text from '@instructure/ui-elements/lib/components/Text'
import I18n from 'i18n!assignment_grade_summary'
import GridRow from './GridRow'
export default class Grid extends Component {
static propTypes = {
graders: arrayOf(
shape({
graderName: string,
graderId: string.isRequired
})
).isRequired,
grades: shape({}).isRequired,
rows: arrayOf(
shape({
studentId: string.isRequired,
studentName: string.isRequired
}).isRequired
).isRequired
}
shouldComponentUpdate(nextProps) {
return Object.keys(nextProps).some(key => this.props[key] !== nextProps[key])
}
render() {
return (
<div className="GradesGrid">
<table>
<thead className="GradesGrid__Header">
<tr className="GradesGrid__HeaderRow">
<th className="GradesGrid__StudentColumnHeader" scope="col">
<Text>{I18n.t('Student')}</Text>
</th>
{this.props.graders.map((grader, index) => (
<th className="GradesGrid__GraderHeader" key={grader.graderId} scope="col">
<Text>
{grader.graderName ||
I18n.t('Grader %{graderNumber}', {graderNumber: I18n.n(index + 1)})}
</Text>
</th>
))}
</tr>
</thead>
<tbody className="GradesGrid__Body">
{this.props.rows.map((row, index) => (
<GridRow
graders={this.props.graders}
grades={this.props.grades[row.studentId]}
key={index}
row={row}
/>
))}
</tbody>
</table>
</div>
)
}
}

View File

@ -0,0 +1,71 @@
/*
* 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, 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) : ''
}
export default class GridRow extends Component {
static propTypes = {
graders: arrayOf(
shape({
graderName: string,
graderId: string.isRequired
})
).isRequired,
grades: shape({}),
row: shape({
studentId: string.isRequired,
studentName: string.isRequired
}).isRequired
}
static defaultProps = {
grades: {}
}
shouldComponentUpdate(nextProps) {
return Object.keys(nextProps).some(key => this.props[key] !== nextProps[key])
}
render() {
return (
<tr className={`GradesGrid__BodyRow student_${this.props.row.studentId}`}>
<th className="GradesGrid__BodyRowHeader" scope="row">
<Text>{this.props.row.studentName}</Text>
</th>
{this.props.graders.map(grader => {
const classNames = ['GradesGrid__ProvisionalGradeCell', `grader_${grader.graderId}`]
return (
<td className={classNames.join(' ')} key={grader.graderId}>
<Text>{getGrade(grader.graderId, this.props.grades)}</Text>
</td>
)
})}
</tr>
)
}
}

View File

@ -0,0 +1,73 @@
/*
* 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, shape, string} from 'prop-types'
import I18n from 'i18n!assignment_grade_summary'
import Grid from './Grid'
function studentToRow(student, index) {
return {
studentId: student.id,
studentName:
student.displayName || I18n.t('Student %{studentNumber}', {studentNumber: I18n.n(index + 1)})
}
}
export default class GradesGrid extends Component {
static propTypes = {
graders: arrayOf(
shape({
graderName: string,
graderId: string.isRequired
})
).isRequired,
grades: shape({}).isRequired,
students: arrayOf(
shape({
displayName: string,
id: string.isRequired
}).isRequired
).isRequired
}
constructor(props) {
super(props)
this.state = {
rows: this.props.students.map(studentToRow)
}
}
componentWillReceiveProps(nextProps) {
if (nextProps.students !== this.props.students) {
this.setState({
rows: nextProps.students.map(studentToRow)
})
}
}
render() {
return (
<div className="GradesGridContainer">
<Grid graders={this.props.graders} grades={this.props.grades} rows={this.state.rows} />
</div>
)
}
}

View File

@ -16,51 +16,96 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import {arrayOf, shape, string} from 'prop-types'
import React, {Component} from 'react'
import {arrayOf, func, shape, string} from 'prop-types'
import {connect} from 'react-redux'
import Text from '@instructure/ui-elements/lib/components/Text'
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 {loadStudents} from '../students/StudentActions'
import GradesGrid from './GradesGrid'
import Header from './Header'
function Layout(props) {
if (props.graders.length === 0) {
class Layout extends Component {
static propTypes = {
assignment: shape({
title: string.isRequired
}).isRequired,
graders: arrayOf(
shape({
graderId: string.isRequired
})
).isRequired,
loadStudents: func.isRequired,
provisionalGrades: shape({}).isRequired,
students: arrayOf(
shape({
id: string.isRequired
})
).isRequired
}
componentDidMount() {
if (this.props.graders.length) {
this.props.loadStudents()
}
}
render() {
if (this.props.graders.length === 0) {
return (
<div>
<Header assignment={this.props.assignment} />
<View as="div" margin="medium 0 0 0">
<Text color="warning">
{I18n.t(
'Moderation is unable to occur at this time due to grades not being submitted.'
)}
</Text>
</View>
</div>
)
}
return (
<div>
<Header assignment={props.assignment} />
<Header assignment={this.props.assignment} />
<Text>
{I18n.t('Moderation is unable to occur at this time due to grades not being submitted.')}
</Text>
<View as="div" margin="large 0 0 0">
{this.props.students.length > 0 ? (
<GradesGrid
graders={this.props.graders}
grades={this.props.provisionalGrades}
students={this.props.students}
/>
) : (
<Spinner title={I18n.t('Students are loading')} />
)}
</View>
</div>
)
}
return (
<div>
<Header assignment={props.assignment} />
</div>
)
}
Layout.propTypes = {
assignment: shape({
title: string.isRequired
}).isRequired,
graders: arrayOf(
shape({
graderId: string.isRequired
})
).isRequired
}
function mapStateToProps(state) {
return {
assignment: state.context.assignment,
graders: state.context.graders
graders: state.context.graders,
provisionalGrades: state.grades.provisionalGrades,
students: state.students.list
}
}
export default connect(mapStateToProps)(Layout)
function mapDispatchToProps(dispatch) {
return {
loadStudents() {
dispatch(loadStudents())
}
}
}
export default connect(mapStateToProps, mapDispatchToProps)(Layout)

View File

@ -19,6 +19,9 @@
import {applyMiddleware, combineReducers, createStore} from 'redux'
import ReduxThunk from 'redux-thunk'
import gradesReducer from './grades/gradesReducer'
import studentsReducer from './students/studentsReducer'
const createStoreWithMiddleware = applyMiddleware(ReduxThunk)(createStore)
export default function configureStore(env) {
@ -29,7 +32,9 @@ export default function configureStore(env) {
}
const reducer = combineReducers({
context: contextReducer
context: contextReducer,
grades: gradesReducer,
students: studentsReducer
})
return createStoreWithMiddleware(reducer)

View File

@ -0,0 +1,23 @@
/*
* 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/>.
*/
export const ADD_PROVISIONAL_GRADES = 'ADD_PROVISIONAL_GRADES'
export function addProvisionalGrades(provisionalGrades) {
return {type: ADD_PROVISIONAL_GRADES, payload: {provisionalGrades}}
}

View File

@ -0,0 +1,38 @@
/*
* 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 buildReducer from '../buildReducer'
import {ADD_PROVISIONAL_GRADES} from './GradeActions'
function addProvisionalGrades(state, grades) {
const provisionalGrades = {...state.provisionalGrades}
grades.forEach(grade => {
provisionalGrades[grade.studentId] = provisionalGrades[grade.studentId] || {}
provisionalGrades[grade.studentId][grade.graderId] = grade
})
return {...state, provisionalGrades}
}
const handlers = {}
handlers[ADD_PROVISIONAL_GRADES] = (state, action) =>
addProvisionalGrades(state, action.payload.provisionalGrades)
export default buildReducer(handlers, {
provisionalGrades: {}
})

View File

@ -0,0 +1,55 @@
/*
* 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 {addProvisionalGrades} from '../grades/GradeActions'
import * as StudentsApi from './StudentsApi'
export const ADD_STUDENTS = 'ADD_STUDENTS'
export const FAILURE = 'FAILURE'
export const SET_LOAD_STUDENTS_STATUS = 'SET_LOAD_STUDENTS_STATUS'
export const STARTED = 'STARTED'
export const SUCCESS = 'SUCCESS'
export function addStudents(students) {
return {type: ADD_STUDENTS, payload: {students}}
}
export function setLoadStudentsStatus(status) {
return {type: SET_LOAD_STUDENTS_STATUS, payload: {status}}
}
export function loadStudents() {
return (dispatch, getState) => {
const {assignment} = getState().context
dispatch(setLoadStudentsStatus(STARTED))
StudentsApi.loadStudents(assignment.courseId, assignment.id, {
onAllPagesLoaded() {
dispatch(setLoadStudentsStatus(SUCCESS))
},
onFailure() {
dispatch(setLoadStudentsStatus(FAILURE))
},
onPageLoaded({provisionalGrades, students}) {
dispatch(addStudents(students))
dispatch(addProvisionalGrades(provisionalGrades))
}
})
}
}

View File

@ -0,0 +1,76 @@
/*
* 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'
import parseLinkHeader from '../../../shared/helpers/parseLinkHeader'
const STUDENTS_PER_PAGE = 50
function normalizeStudentPage(data) {
const students = []
const provisionalGrades = []
data.forEach(studentDatum => {
const studentId = studentDatum.id
students.push({
displayName: studentDatum.display_name,
id: studentId
})
studentDatum.provisional_grades.forEach(gradeDatum => {
provisionalGrades.push({
grade: gradeDatum.grade,
graderId: gradeDatum.scorer_id || gradeDatum.anonymous_grader_id,
id: gradeDatum.provisional_grade_id,
score: gradeDatum.score,
selected: studentDatum.selected_provisional_grade_id === gradeDatum.provisional_grade_id,
studentId
})
})
})
return {provisionalGrades, students}
}
function getAllStudentsPages(url, callbacks) {
axios
.get(url)
.then(response => {
callbacks.onPageLoaded(normalizeStudentPage(response.data))
const linkHeaders = parseLinkHeader(response)
if (linkHeaders.next) {
getAllStudentsPages(linkHeaders.next, callbacks)
} else {
callbacks.onAllPagesLoaded()
}
})
.catch(response => {
callbacks.onFailure(response)
return Promise.reject(response) // allow for shared error handling
})
}
/* eslint-disable import/prefer-default-export */
export function loadStudents(courseId, assignmentId, callbacks) {
const queryParams = `include[]=provisional_grades&per_page=${STUDENTS_PER_PAGE}`
const url = `/api/v1/courses/${courseId}/assignments/${assignmentId}/gradeable_students?${queryParams}`
getAllStudentsPages(url, callbacks)
}

View File

@ -0,0 +1,40 @@
/*
* 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 buildReducer from '../buildReducer'
import {ADD_STUDENTS, SET_LOAD_STUDENTS_STATUS} from './StudentActions'
function addStudents(state, students) {
return {...state, list: [...state.list, ...students]}
}
function setLoadStudentsStatus(state, loadStudentsStatus) {
return {...state, loadStudentsStatus}
}
const handlers = {}
handlers[ADD_STUDENTS] = (state, {payload}) => addStudents(state, payload.students)
handlers[SET_LOAD_STUDENTS_STATUS] = (state, {payload}) =>
setLoadStudentsStatus(state, payload.status)
export default buildReducer(handlers, {
list: [],
loadStudentsStatus: null
})

View File

@ -18,6 +18,12 @@
@import "base/environment";
$headerHeight: 50px;
$rowHeight: 50px;
$graderColumnWidth: 116px;
$studentColumnWidth: 200px;
$maxGridBodyHeight: $rowHeight * 10;
.ic-Layout-columns {
display: flex;
}
@ -27,3 +33,77 @@
height: 100%;
width: 100%;
}
.GradesGridContainer {
overflow: hidden;
}
/* GradesGrid */
.GradesGrid {
overflow-x: auto;
&, * {
box-sizing: border-box;
}
tr {
display: block;
white-space: nowrap;
}
th, td {
display: inline-block;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.GradesGrid__Header {
display: block;
}
.GradesGrid__HeaderRow {
height: $headerHeight;
line-height: $headerHeight;
}
.GradesGrid__GraderHeader {
overflow: hidden;
padding: 0 $ic-sp;
text-align: center;
text-overflow: ellipsis;
width: $graderColumnWidth;
}
.GradesGrid__StudentColumnHeader {
padding: 0 $ic-sp;
text-align: start;
text-overflow: ellipsis;
width: $studentColumnWidth;
}
.GradesGrid__Body {
display: block;
max-height: $maxGridBodyHeight;
overflow-y: auto;
}
.GradesGrid__BodyRow {
height: $rowHeight;
line-height: $rowHeight;
}
.GradesGrid__BodyRowHeader {
font-weight: normal;
padding: 0 $ic-sp;
text-align: start;
width: $studentColumnWidth;
}
.GradesGrid__ProvisionalGradeCell {
text-align: center;
width: $graderColumnWidth;
}
/* End GradesGrid */

View File

@ -4241,6 +4241,28 @@ describe 'Submissions API', type: :request do
api_call_as_user(@ta, :get, @path, @params, {}, {}, { :expected_status => 401 })
end
context "when Anonymous Moderated Marking is enabled" do
before(:once) { @course.root_account.enable_feature!(:anonymous_moderated_marking) }
it "is unauthorized when the user is not the assigned final grader" do
api_call_as_user(@teacher, :get, @path, @params, {}, {}, expected_status: 401)
end
it "is unauthorized when the user is an account admin without 'Select Final Grade for Moderation' permission" do
@course.account.role_overrides.create!(role: admin_role, enabled: false, permission: :select_final_grade)
api_call_as_user(account_admin_user, :get, @path, @params, {}, {}, expected_status: 401)
end
it "is authorized when the user is the final grader" do
@assignment.update!(final_grader: @teacher, grader_count: 2)
api_call_as_user(@teacher, :get, @path, @params, {}, {}, expected_status: 200)
end
it "is authorized when the user is an account admin with 'Select Final Grade for Moderation' permission" do
api_call_as_user(account_admin_user, :get, @path, @params, {}, {}, expected_status: 200)
end
end
it "includes provisional grades with selections" do
sub = @assignment.grade_student(@student1, :score => 90, :grader => @ta, :provisional => true).first
pg = sub.provisional_grades.first

View File

@ -0,0 +1,118 @@
/*
* 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 GridRow from 'jsx/assignments/GradeSummary/components/GradesGrid/GridRow'
QUnit.module('GradeSummary GridRow', suiteHooks => {
let props
let wrapper
suiteHooks.beforeEach(() => {
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: false,
studentId: '1111'
}
},
row: {
studentId: '1111',
studentName: 'Adam Jones'
}
}
})
suiteHooks.afterEach(() => {
wrapper.unmount()
})
function mountComponent() {
// React is unable to render partial table structures.
wrapper = mount(
<table>
<tbody>
<GridRow {...props} />
</tbody>
</table>
)
}
test('displays the student name in the row header', () => {
mountComponent()
const header = wrapper.find('th.GradesGrid__BodyRowHeader')
equal(header.text(), 'Adam Jones')
})
test('includes a cell for each grader', () => {
mountComponent()
strictEqual(wrapper.find('td.GradesGrid__ProvisionalGradeCell').length, 2)
})
test('displays the score of a provisional grade in the matching cell', () => {
mountComponent()
const cell = wrapper.find('td.grader_1102')
equal(cell.text(), '8.9')
})
test('displays zero scores', () => {
props.grades[1101].score = 0
mountComponent()
const cell = wrapper.find('td.grader_1101')
equal(cell.text(), '0')
})
test('displays "" (en dash) when the student grade for a given grader was cleared', () => {
props.grades[1101].score = null
mountComponent()
const cell = wrapper.find('td.grader_1101')
equal(cell.text(), '')
})
test('displays "" (en dash) when the student was not graded by a given grader', () => {
delete props.grades[1101]
mountComponent()
const cell = wrapper.find('td.grader_1101')
equal(cell.text(), '')
})
test('displays "" (en dash) when the student has no provisional grades', () => {
delete props.grades
mountComponent()
const cell = wrapper.find('td.grader_1101')
equal(cell.text(), '')
})
})

View File

@ -0,0 +1,133 @@
/*
* 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 Grid from 'jsx/assignments/GradeSummary/components/GradesGrid/Grid'
QUnit.module('GradeSummary Grid', suiteHooks => {
let props
let wrapper
suiteHooks.beforeEach(() => {
props = {
graders: [
{graderId: '1101', graderName: 'Miss Frizzle'},
{graderId: '1102', graderName: 'Mr. Keating'}
],
grades: {
1111: {
1101: {
grade: 'A',
graderId: '1101',
id: '4601',
score: 10,
selected: false,
studentId: '1111'
},
1102: {
grade: 'B',
graderId: '1102',
id: '4602',
score: 8.9,
selected: false,
studentId: '1111'
}
},
1112: {
1102: {
grade: 'C',
graderId: '1102',
id: '4603',
score: 7.8,
selected: false,
studentId: '1112'
}
},
1113: {
1101: {
grade: 'A',
graderId: '1101',
id: '4604',
score: 10,
selected: false,
studentId: '1113'
}
}
},
rows: [
{studentId: '1111', studentName: 'Adam Jones'},
{studentId: '1112', studentName: 'Betty Ford'},
{studentId: '1113', studentName: 'Charlie Xi'},
{studentId: '1114', studentName: 'Dana Smith'}
]
}
})
suiteHooks.afterEach(() => {
wrapper.unmount()
})
function mountComponent() {
wrapper = mount(<Grid {...props} />)
}
test('includes a column header for each grader', () => {
mountComponent()
strictEqual(wrapper.find('th.GradesGrid__GraderHeader').length, 2)
})
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)
})
test('sends graders to each GridRow', () => {
mountComponent()
wrapper.find('GridRow').forEach(gridRow => {
strictEqual(gridRow.prop('graders'), props.graders)
})
})
test('sends student-specific grades to each GridRow', () => {
mountComponent()
const gridRow = wrapper.find('GridRow').at(1)
strictEqual(gridRow.prop('grades'), props.grades[1112])
})
test('sends the related row to each GridRow', () => {
mountComponent()
const gridRow = wrapper.find('GridRow').at(1)
strictEqual(gridRow.prop('row'), props.rows[1])
})
})

View File

@ -0,0 +1,149 @@
/*
* 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 GradesGrid from 'jsx/assignments/GradeSummary/components/GradesGrid'
QUnit.module('GradeSummary GradesGrid', suiteHooks => {
let props
let wrapper
suiteHooks.beforeEach(() => {
props = {
graders: [
{graderId: '1101', graderName: 'Miss Frizzle'},
{graderId: '1102', graderName: 'Mr. Keating'}
],
grades: {
1111: {
1101: {
grade: 'A',
graderId: '1101',
id: '4601',
score: 10,
selected: false,
studentId: '1111'
},
1102: {
grade: 'B',
graderId: '1102',
id: '4602',
score: 8.9,
selected: false,
studentId: '1111'
}
},
1112: {
1102: {
grade: 'C',
graderId: '1102',
id: '4603',
score: 7.8,
selected: false,
studentId: '1112'
}
},
1113: {
1101: {
grade: 'A',
graderId: '1101',
id: '4604',
score: 10,
selected: false,
studentId: '1113'
}
}
},
students: [
{id: '1111', displayName: 'Adam Jones'},
{id: '1112', displayName: 'Betty Ford'},
{id: '1113', displayName: 'Charlie Xi'},
{id: '1114', displayName: 'Dana Smith'}
]
}
})
suiteHooks.afterEach(() => {
wrapper.unmount()
})
function mountComponent() {
wrapper = mount(<GradesGrid {...props} />)
}
function getGraderNames() {
const headers = wrapper.find('th.GradesGrid__GraderHeader')
return headers.map(header => header.text())
}
function getStudentNames() {
const headers = wrapper.find('th.GradesGrid__BodyRowHeader')
return headers.map(header => header.text())
}
test('displays the grader names in the column headers', () => {
mountComponent()
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('adds rows as students are added', () => {
const {students} = props
props.students = students.slice(0, 2)
mountComponent()
wrapper.setProps({students})
strictEqual(wrapper.find('tr.GradesGrid__BodyRow').length, 4)
})
test('displays the student names in the row headers', () => {
mountComponent()
deepEqual(getStudentNames(), ['Adam Jones', 'Betty Ford', 'Charlie Xi', 'Dana Smith'])
})
test('enumerates students for names when students are anonymous', () => {
for (let i = 0; i < props.students.length; i++) {
props.students[i].displayName = null
}
mountComponent()
deepEqual(getStudentNames(), ['Student 1', 'Student 2', 'Student 3', 'Student 4'])
})
test('enumerates additional students for names as they are added', () => {
for (let i = 0; i < props.students.length; i++) {
props.students[i].displayName = null
}
const {students} = props
props.students = students.slice(0, 2)
mountComponent()
wrapper.setProps({students})
deepEqual(getStudentNames(), ['Student 1', 'Student 2', 'Student 3', 'Student 4'])
})
})

View File

@ -20,6 +20,7 @@ import React from 'react'
import {mount} from 'enzyme'
import {Provider} from 'react-redux'
import * as StudentActions from 'jsx/assignments/GradeSummary/students/StudentActions'
import Layout from 'jsx/assignments/GradeSummary/components/Layout'
import configureStore from 'jsx/assignments/GradeSummary/configureStore'
@ -35,11 +36,18 @@ QUnit.module('GradeSummary Layout', suiteHooks => {
id: '2301',
title: 'Example Assignment'
},
graders: [{graderId: '1101'}, {graderId: '1102'}]
graders: [
{graderId: '1101', graderName: 'Miss Frizzle'},
{graderId: '1102', graderName: 'Mr. Keating'}
]
}
sinon
.stub(StudentActions, 'loadStudents')
.returns(StudentActions.setLoadStudentsStatus(StudentActions.LOAD_STUDENTS_STARTED))
})
suiteHooks.afterEach(() => {
StudentActions.loadStudents.restore()
wrapper.unmount()
})
@ -57,14 +65,56 @@ QUnit.module('GradeSummary Layout', suiteHooks => {
strictEqual(wrapper.find('Header').length, 1)
})
test('includes a "no graders" message when there are no graders', () => {
storeEnv.graders = []
mountComponent()
ok(wrapper.text().includes('Moderation is unable to occur'))
})
test('excludes the "no graders" message when there are graders', () => {
mountComponent()
notOk(wrapper.text().includes('Moderation is unable to occur'))
})
test('loads students upon mounting', () => {
mountComponent()
strictEqual(StudentActions.loadStudents.callCount, 1)
})
QUnit.module('when there are no graders', hooks => {
hooks.beforeEach(() => {
storeEnv.graders = []
})
test('includes a "no graders" message when there are no graders', () => {
mountComponent()
ok(wrapper.text().includes('Moderation is unable to occur'))
})
test('does not load students', () => {
mountComponent()
strictEqual(StudentActions.loadStudents.callCount, 0)
})
})
QUnit.module('when students have not yet loaded', () => {
test('displays a spinner', () => {
mountComponent()
strictEqual(wrapper.find('Spinner').length, 1)
})
})
QUnit.module('when some students have loaded', hooks => {
let students
hooks.beforeEach(() => {
students = [{id: '1111', displayName: 'Adam Jones'}, {id: '1112', displayName: 'Betty Ford'}]
})
test('renders the GradesGrid', () => {
mountComponent()
store.dispatch(StudentActions.addStudents(students))
strictEqual(wrapper.find('GradesGrid').length, 1)
})
test('does not display a spinner', () => {
mountComponent()
store.dispatch(StudentActions.addStudents(students))
strictEqual(wrapper.find('Spinner').length, 0)
})
})
})

View File

@ -20,7 +20,7 @@ import fakeENV from 'helpers/fakeENV'
import getEnv from 'jsx/assignments/GradeSummary/getEnv'
QUnit.module('GradeSummary getEnv', suiteHooks => {
QUnit.module('GradeSummary getEnv()', suiteHooks => {
suiteHooks.beforeEach(() => {
fakeENV.setup({
ASSIGNMENT: {

View File

@ -0,0 +1,59 @@
/*
* 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 GradeActions from 'jsx/assignments/GradeSummary/grades/GradeActions'
import configureStore from 'jsx/assignments/GradeSummary/configureStore'
QUnit.module('GradeSummary GradeActions', suiteHooks => {
let store
suiteHooks.beforeEach(() => {
store = configureStore({
assignment: {
courseId: '1201',
id: '2301',
title: 'Example Assignment'
},
graders: [{graderId: '1101'}, {graderId: '1102'}]
})
})
QUnit.module('.addProvisionalGrades()', () => {
test('adds provisional grades to the store', () => {
const provisionalGrades = [
{
grade: 'A',
graderId: '1101',
id: '4601',
score: 10,
studentId: '1111'
},
{
grade: 'B',
graderId: '1102',
id: '4602',
score: 9,
studentId: '1112'
}
]
store.dispatch(GradeActions.addProvisionalGrades(provisionalGrades))
const grades = store.getState().grades.provisionalGrades
deepEqual(grades[1112][1102], provisionalGrades[1])
})
})
})

View File

@ -0,0 +1,92 @@
/*
* 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 GradeActions from 'jsx/assignments/GradeSummary/grades/GradeActions'
import configureStore from 'jsx/assignments/GradeSummary/configureStore'
QUnit.module('GradeSummary gradesReducer()', suiteHooks => {
let store
let provisionalGrades
suiteHooks.beforeEach(() => {
store = configureStore({
assignment: {
courseId: '1201',
id: '2301',
title: 'Example Assignment'
},
graders: [
{graderId: '1101', graderName: 'Miss Frizzle'},
{graderId: '1102', graderName: 'Mr. Keating'}
]
})
provisionalGrades = [
{
grade: 'A',
graderId: '1101',
id: '4601',
score: 10,
selected: true,
studentId: '1111'
},
{
grade: 'B',
graderId: '1102',
id: '4602',
score: 9,
selected: false,
studentId: '1112'
},
{
grade: 'C',
graderId: '1102',
id: '4603',
score: 8,
selected: false,
studentId: '1111'
}
]
})
function getProvisionalGrades() {
return store.getState().grades.provisionalGrades
}
QUnit.module('when handling "ADD_PROVISIONAL_GRADES"', () => {
test('adds a key for each student among the provisional grades', () => {
store.dispatch(GradeActions.addProvisionalGrades(provisionalGrades))
deepEqual(Object.keys(getProvisionalGrades()).sort(), ['1111', '1112'])
})
test('adds a key to a student for each grader who graded that student', () => {
store.dispatch(GradeActions.addProvisionalGrades(provisionalGrades))
deepEqual(Object.keys(getProvisionalGrades()[1111]).sort(), ['1101', '1102'])
})
test('does not add a key to a student for a grader who has not graded that student', () => {
store.dispatch(GradeActions.addProvisionalGrades(provisionalGrades))
deepEqual(Object.keys(getProvisionalGrades()[1112]), ['1102'])
})
test('keys a grade to the grader id within the student map', () => {
store.dispatch(GradeActions.addProvisionalGrades(provisionalGrades))
deepEqual(getProvisionalGrades()[1112][1102], provisionalGrades[1])
})
})
})

View File

@ -0,0 +1,121 @@
/*
* 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 StudentActions from 'jsx/assignments/GradeSummary/students/StudentActions'
import * as StudentsApi from 'jsx/assignments/GradeSummary/students/StudentsApi'
import configureStore from 'jsx/assignments/GradeSummary/configureStore'
QUnit.module('GradeSummary StudentActions', suiteHooks => {
let store
suiteHooks.beforeEach(() => {
store = configureStore({
assignment: {
courseId: '1201',
id: '2301',
title: 'Example Assignment'
},
graders: [{graderId: '1101'}, {graderId: '1102'}]
})
})
QUnit.module('.loadStudents()', hooks => {
let args
let provisionalGrades
let students
hooks.beforeEach(() => {
sinon.stub(StudentsApi, 'loadStudents').callsFake((courseId, assignmentId, callbacks) => {
args = {courseId, assignmentId, callbacks}
})
provisionalGrades = [
{
grade: 'A',
graderId: '1101',
id: '4601',
score: 10,
studentId: '1111'
},
{
grade: 'B',
graderId: '1102',
id: '4602',
score: 9,
studentId: '1112'
}
]
students = [{id: '1111', displayName: 'Adam Jones'}, {id: '1112', displayName: 'Betty Ford'}]
})
hooks.afterEach(() => {
args = null
StudentsApi.loadStudents.restore()
})
test('sets the "load students" status to "started"', () => {
store.dispatch(StudentActions.loadStudents())
const {loadStudentsStatus} = store.getState().students
equal(loadStudentsStatus, StudentActions.STARTED)
})
test('loads students through the api', () => {
store.dispatch(StudentActions.loadStudents())
strictEqual(StudentsApi.loadStudents.callCount, 1)
})
test('includes the course id when loading students through the api', () => {
store.dispatch(StudentActions.loadStudents())
strictEqual(args.courseId, '1201')
})
test('includes the assignment id when loading students through the api', () => {
store.dispatch(StudentActions.loadStudents())
strictEqual(args.assignmentId, '2301')
})
test('adds students to the store when a page of students is loaded', () => {
store.dispatch(StudentActions.loadStudents())
args.callbacks.onPageLoaded({provisionalGrades, students})
const storedStudents = store.getState().students.list
deepEqual(storedStudents, students)
})
test('adds provisional grades to the store when a page of students is loaded', () => {
store.dispatch(StudentActions.loadStudents())
args.callbacks.onPageLoaded({provisionalGrades, students})
const grades = store.getState().grades.provisionalGrades
deepEqual(grades[1112][1102], provisionalGrades[1])
})
test('sets the "load students" status to "success" when all pages have loaded', () => {
store.dispatch(StudentActions.loadStudents())
args.callbacks.onAllPagesLoaded()
const {loadStudentsStatus} = store.getState().students
equal(loadStudentsStatus, StudentActions.SUCCESS)
})
test('sets the "load students" status to "failure" when a failure occurs', () => {
store.dispatch(StudentActions.loadStudents())
args.callbacks.onFailure()
const {loadStudentsStatus} = store.getState().students
equal(loadStudentsStatus, StudentActions.FAILURE)
})
})
})

View File

@ -0,0 +1,261 @@
/*
* 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 StudentsApi from 'jsx/assignments/GradeSummary/students/StudentsApi'
import FakeServer, {paramsFromRequest, pathFromRequest} from 'jsx/__tests__/FakeServer'
QUnit.module('GradeSummary StudentsApi', 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('.loadStudents()', hooks => {
const url = '/api/v1/courses/1201/assignments/2301/gradeable_students'
let loadedProvisionalGrades
let loadedStudents
let provisionalGradesData
let studentsData
hooks.beforeEach(() => {
provisionalGradesData = [
{
grade: 'A',
provisional_grade_id: '4601',
score: 10,
scorer_id: '1101'
},
{
grade: 'B',
provisional_grade_id: '4602',
score: 9,
scorer_id: '1102'
},
{
grade: 'C',
provisional_grade_id: '4603',
score: 8,
scorer_id: '1101'
},
{
grade: 'B-',
provisional_grade_id: '4604',
score: 8.9,
scorer_id: '1102'
}
]
studentsData = [
{display_name: 'Adam Jones', id: '1111', provisional_grades: [provisionalGradesData[0]]},
{
display_name: 'Betty Ford',
id: '1112',
provisional_grades: provisionalGradesData.slice(1, 3),
selected_provisional_grade_id: '4603'
},
{display_name: 'Charlie Xi', id: '1113', provisional_grades: []},
{display_name: 'Dana Smith', id: '1114', provisional_grades: [provisionalGradesData[3]]}
]
server
.for(url)
.respond([
{status: 200, body: [studentsData[0]]},
{status: 200, body: [studentsData[1]]},
{status: 200, body: studentsData.slice(2)}
])
loadedProvisionalGrades = []
loadedStudents = []
})
async function loadStudents() {
let resolvePromise
let rejectPromise
const promise = new Promise((resolve, reject) => {
resolvePromise = resolve
rejectPromise = reject
})
StudentsApi.loadStudents('1201', '2301', {
onAllPagesLoaded: resolvePromise,
onFailure: rejectPromise,
onPageLoaded({provisionalGrades, students}) {
loadedProvisionalGrades.push(provisionalGrades)
loadedStudents.push(students)
}
})
await promise
}
function flatten(nestedArrays) {
return [].concat(...nestedArrays)
}
function sortBy(array, key) {
return [].concat(array).sort((a, b) => {
if (a[key] === b[key]) {
return 0
}
return a[key] < b[key] ? -1 : 1
})
}
test('sends a request for students', async () => {
await loadStudents()
const request = server.receivedRequests[0]
equal(pathFromRequest(request), '/api/v1/courses/1201/assignments/2301/gradeable_students')
})
test('includes provisional grades', async () => {
await loadStudents()
const request = server.receivedRequests[0]
deepEqual(paramsFromRequest(request).include, ['provisional_grades'])
})
test('requests 50 students per page', async () => {
await loadStudents()
const request = server.receivedRequests[0]
strictEqual(paramsFromRequest(request).per_page, '50')
})
test('sends additional requests while additional pages are available', async () => {
await loadStudents()
strictEqual(server.receivedRequests.length, 3)
})
test('calls onPageLoaded for each successful request', async () => {
await loadStudents()
strictEqual(loadedStudents.length, 3)
})
test('includes students when calling onPageLoaded', async () => {
await loadStudents()
const studentCountPerPage = loadedStudents.map(pageStudents => pageStudents.length)
deepEqual(studentCountPerPage, [1, 1, 2])
})
test('normalizes student names', async () => {
await loadStudents()
const names = flatten(loadedStudents).map(student => student.displayName)
deepEqual(names.sort(), ['Adam Jones', 'Betty Ford', 'Charlie Xi', 'Dana Smith'])
})
test('includes ids on students', async () => {
await loadStudents()
const ids = flatten(loadedStudents).map(student => student.id)
deepEqual(ids.sort(), ['1111', '1112', '1113', '1114'])
})
test('includes provisional grades when calling onPageLoaded', async () => {
await loadStudents()
const gradeCountPerPage = loadedProvisionalGrades.map(pageGrades => pageGrades.length)
deepEqual(gradeCountPerPage, [1, 2, 1])
})
test('normalizes provisional grade grader ids', async () => {
await loadStudents()
const grades = sortBy(flatten(loadedProvisionalGrades), 'id')
deepEqual(grades.map(grade => grade.graderId), ['1101', '1102', '1101', '1102'])
})
test('uses anonymous grader id for provisional grades when graders are anonymous', async () => {
for (let i = 0; i < provisionalGradesData.length; i++) {
delete provisionalGradesData[i].scorer_id
provisionalGradesData[i].anonymous_grader_id = `abcd${i + 1}`
}
await loadStudents()
const grades = sortBy(flatten(loadedProvisionalGrades), 'id')
deepEqual(grades.map(grade => grade.graderId), ['abcd1', 'abcd2', 'abcd3', 'abcd4'])
})
test('includes provisional grade ids', async () => {
await loadStudents()
const grades = sortBy(flatten(loadedProvisionalGrades), 'id')
deepEqual(grades.map(grade => grade.grade), ['A', 'B', 'C', 'B-'])
})
test('includes provisional grade grades', async () => {
await loadStudents()
const grades = sortBy(flatten(loadedProvisionalGrades), 'id')
deepEqual(grades.map(grade => grade.grade), ['A', 'B', 'C', 'B-'])
})
test('includes provisional grade scores', async () => {
await loadStudents()
const grades = sortBy(flatten(loadedProvisionalGrades), 'id')
deepEqual(grades.map(grade => grade.score), [10, 9, 8, 8.9])
})
test('sets selection state on provisional grades', async () => {
await loadStudents()
const grades = sortBy(flatten(loadedProvisionalGrades), 'id')
deepEqual(grades.map(grade => grade.selected), [false, false, true, false])
})
test('includes associated student id', async () => {
await loadStudents()
const grades = sortBy(flatten(loadedProvisionalGrades), 'id')
deepEqual(grades.map(grade => grade.studentId), ['1111', '1112', '1112', '1114'])
})
test('calls onFailure when a request fails', async () => {
server.unsetResponses(url)
server
.for(url)
.respond([
{status: 200, body: [studentsData[0]]},
{status: 500, body: {error: 'server error'}}
])
try {
await loadStudents()
} catch (e) {
ok(e.message.includes('500'))
}
})
test('does not send additional requests when one fails', async () => {
server.unsetResponses(url)
server
.for(url)
.respond([
{status: 200, body: [studentsData[0]]},
{status: 500, body: {error: 'server error'}}
])
try {
await loadStudents()
} catch (e) {
strictEqual(server.receivedRequests.length, 2)
}
})
})
})

View File

@ -0,0 +1,92 @@
/*
* 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 {
FAILURE,
STARTED,
SUCCESS,
addStudents,
setLoadStudentsStatus
} from 'jsx/assignments/GradeSummary/students/StudentActions'
import configureStore from 'jsx/assignments/GradeSummary/configureStore'
QUnit.module('GradeSummary studentsReducer()', suiteHooks => {
let store
let students
suiteHooks.beforeEach(() => {
store = configureStore({
assignment: {
courseId: '1201',
id: '2301',
title: 'Example Assignment'
},
graders: [{graderId: '1101'}, {graderId: '1102'}]
})
students = [
{id: '1111', displayName: 'Adam Jones'},
{id: '1112', displayName: 'Betty Ford'},
{id: '1113', displayName: 'Charlie Xi'},
{id: '1114', displayName: 'Dana Young'}
]
})
QUnit.module('when handling "ADD_STUDENTS"', () => {
test('adds students to the store', () => {
store.dispatch(addStudents(students))
const storedStudents = store.getState().students.list
deepEqual(storedStudents, students)
})
test('appends students to the end of the current list of students', () => {
store.dispatch(addStudents(students.slice(0, 2)))
store.dispatch(addStudents(students.slice(2)))
const storedStudents = store.getState().students.list
deepEqual(storedStudents, students)
})
test('preserves the order of students as they are added', () => {
store.dispatch(addStudents(students.slice(2)))
store.dispatch(addStudents(students.slice(0, 2)))
const storedStudents = store.getState().students.list
deepEqual(storedStudents.map(student => student.id), ['1113', '1114', '1111', '1112'])
})
})
QUnit.module('when handling "SET_LOAD_STUDENTS_STATUS"', () => {
function getLoadStudentsStatus() {
return store.getState().students.loadStudentsStatus
}
test('optionally sets the "load students" status to "failure"', () => {
store.dispatch(setLoadStudentsStatus(FAILURE))
equal(getLoadStudentsStatus(), FAILURE)
})
test('optionally sets the "load students" status to "started"', () => {
store.dispatch(setLoadStudentsStatus(STARTED))
equal(getLoadStudentsStatus(), STARTED)
})
test('optionally sets the "load students" status to "success"', () => {
store.dispatch(setLoadStudentsStatus(SUCCESS))
equal(getLoadStudentsStatus(), SUCCESS)
})
})
})