From c8229d721e88321951b838c1bca48a260617fc0d Mon Sep 17 00:00:00 2001 From: Jeremy Neander Date: Tue, 22 May 2018 19:52:47 -0500 Subject: [PATCH] load students and graders into moderation table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Reviewed-by: Keith T. Garner QA-Review: Indira Pai Product-Review: Sidharth Oberoi --- app/controllers/submissions_api_controller.rb | 2 +- .../components/GradesGrid/Grid.js | 82 ++++++ .../components/GradesGrid/GridRow.js | 71 +++++ .../components/GradesGrid/index.js | 73 +++++ .../GradeSummary/components/Layout.js | 99 +++++-- .../GradeSummary/configureStore.js | 7 +- .../GradeSummary/grades/GradeActions.js | 23 ++ .../GradeSummary/grades/gradesReducer.js | 38 +++ .../GradeSummary/students/StudentActions.js | 55 ++++ .../GradeSummary/students/StudentsApi.js | 76 +++++ .../GradeSummary/students/studentsReducer.js | 40 +++ .../bundles/assignment_grade_summary.scss | 80 ++++++ spec/apis/v1/submissions_api_spec.rb | 22 ++ .../components/GradesGrid/GridRowSpec.js | 118 ++++++++ .../components/GradesGrid/GridSpec.js | 133 +++++++++ .../GradeSummary/components/GradesGridSpec.js | 149 ++++++++++ .../GradeSummary/components/LayoutSpec.js | 64 ++++- .../assignments/GradeSummary/getEnvSpec.js | 2 +- .../GradeSummary/grades/GradeActionsSpec.js | 59 ++++ .../GradeSummary/grades/gradesReducerSpec.js | 92 ++++++ .../students/StudentActionsSpec.js | 121 ++++++++ .../GradeSummary/students/StudentsApiSpec.js | 261 ++++++++++++++++++ .../students/studentsReducerSpec.js | 92 ++++++ 23 files changed, 1722 insertions(+), 37 deletions(-) create mode 100644 app/jsx/assignments/GradeSummary/components/GradesGrid/Grid.js create mode 100644 app/jsx/assignments/GradeSummary/components/GradesGrid/GridRow.js create mode 100644 app/jsx/assignments/GradeSummary/components/GradesGrid/index.js create mode 100644 app/jsx/assignments/GradeSummary/grades/GradeActions.js create mode 100644 app/jsx/assignments/GradeSummary/grades/gradesReducer.js create mode 100644 app/jsx/assignments/GradeSummary/students/StudentActions.js create mode 100644 app/jsx/assignments/GradeSummary/students/StudentsApi.js create mode 100644 app/jsx/assignments/GradeSummary/students/studentsReducer.js create mode 100644 spec/javascripts/jsx/assignments/GradeSummary/components/GradesGrid/GridRowSpec.js create mode 100644 spec/javascripts/jsx/assignments/GradeSummary/components/GradesGrid/GridSpec.js create mode 100644 spec/javascripts/jsx/assignments/GradeSummary/components/GradesGridSpec.js create mode 100644 spec/javascripts/jsx/assignments/GradeSummary/grades/GradeActionsSpec.js create mode 100644 spec/javascripts/jsx/assignments/GradeSummary/grades/gradesReducerSpec.js create mode 100644 spec/javascripts/jsx/assignments/GradeSummary/students/StudentActionsSpec.js create mode 100644 spec/javascripts/jsx/assignments/GradeSummary/students/StudentsApiSpec.js create mode 100644 spec/javascripts/jsx/assignments/GradeSummary/students/studentsReducerSpec.js diff --git a/app/controllers/submissions_api_controller.rb b/app/controllers/submissions_api_controller.rb index 72275510a6a..592823f7efc 100644 --- a/app/controllers/submissions_api_controller.rb +++ b/app/controllers/submissions_api_controller.rb @@ -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 diff --git a/app/jsx/assignments/GradeSummary/components/GradesGrid/Grid.js b/app/jsx/assignments/GradeSummary/components/GradesGrid/Grid.js new file mode 100644 index 00000000000..7938a9bb3c1 --- /dev/null +++ b/app/jsx/assignments/GradeSummary/components/GradesGrid/Grid.js @@ -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 . + */ + +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 ( +
+ + + + + + {this.props.graders.map((grader, index) => ( + + ))} + + + + + {this.props.rows.map((row, index) => ( + + ))} + +
+ {I18n.t('Student')} + + + {grader.graderName || + I18n.t('Grader %{graderNumber}', {graderNumber: I18n.n(index + 1)})} + +
+
+ ) + } +} diff --git a/app/jsx/assignments/GradeSummary/components/GradesGrid/GridRow.js b/app/jsx/assignments/GradeSummary/components/GradesGrid/GridRow.js new file mode 100644 index 00000000000..ce21dcfeaa0 --- /dev/null +++ b/app/jsx/assignments/GradeSummary/components/GradesGrid/GridRow.js @@ -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 . + */ + +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 ( + + + {this.props.row.studentName} + + + {this.props.graders.map(grader => { + const classNames = ['GradesGrid__ProvisionalGradeCell', `grader_${grader.graderId}`] + + return ( + + {getGrade(grader.graderId, this.props.grades)} + + ) + })} + + ) + } +} diff --git a/app/jsx/assignments/GradeSummary/components/GradesGrid/index.js b/app/jsx/assignments/GradeSummary/components/GradesGrid/index.js new file mode 100644 index 00000000000..8bb29362447 --- /dev/null +++ b/app/jsx/assignments/GradeSummary/components/GradesGrid/index.js @@ -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 . + */ + +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 ( +
+ +
+ ) + } +} diff --git a/app/jsx/assignments/GradeSummary/components/Layout.js b/app/jsx/assignments/GradeSummary/components/Layout.js index e77a2cc11ae..ac12ebfc04c 100644 --- a/app/jsx/assignments/GradeSummary/components/Layout.js +++ b/app/jsx/assignments/GradeSummary/components/Layout.js @@ -16,51 +16,96 @@ * with this program. If not, see . */ -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 ( +
+
+ + + + {I18n.t( + 'Moderation is unable to occur at this time due to grades not being submitted.' + )} + + +
+ ) + } + return (
-
+
- - {I18n.t('Moderation is unable to occur at this time due to grades not being submitted.')} - + + {this.props.students.length > 0 ? ( + + ) : ( + + )} +
) } - - return ( -
-
-
- ) -} - -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) diff --git a/app/jsx/assignments/GradeSummary/configureStore.js b/app/jsx/assignments/GradeSummary/configureStore.js index 76e1650006e..20d1e5f5ffe 100644 --- a/app/jsx/assignments/GradeSummary/configureStore.js +++ b/app/jsx/assignments/GradeSummary/configureStore.js @@ -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) diff --git a/app/jsx/assignments/GradeSummary/grades/GradeActions.js b/app/jsx/assignments/GradeSummary/grades/GradeActions.js new file mode 100644 index 00000000000..456bae1278a --- /dev/null +++ b/app/jsx/assignments/GradeSummary/grades/GradeActions.js @@ -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 . + */ + +export const ADD_PROVISIONAL_GRADES = 'ADD_PROVISIONAL_GRADES' + +export function addProvisionalGrades(provisionalGrades) { + return {type: ADD_PROVISIONAL_GRADES, payload: {provisionalGrades}} +} diff --git a/app/jsx/assignments/GradeSummary/grades/gradesReducer.js b/app/jsx/assignments/GradeSummary/grades/gradesReducer.js new file mode 100644 index 00000000000..24a01825b61 --- /dev/null +++ b/app/jsx/assignments/GradeSummary/grades/gradesReducer.js @@ -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 . + */ + +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: {} +}) diff --git a/app/jsx/assignments/GradeSummary/students/StudentActions.js b/app/jsx/assignments/GradeSummary/students/StudentActions.js new file mode 100644 index 00000000000..379b378a465 --- /dev/null +++ b/app/jsx/assignments/GradeSummary/students/StudentActions.js @@ -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 . + */ + +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)) + } + }) + } +} diff --git a/app/jsx/assignments/GradeSummary/students/StudentsApi.js b/app/jsx/assignments/GradeSummary/students/StudentsApi.js new file mode 100644 index 00000000000..d97db1a3c86 --- /dev/null +++ b/app/jsx/assignments/GradeSummary/students/StudentsApi.js @@ -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 . + */ + +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) +} diff --git a/app/jsx/assignments/GradeSummary/students/studentsReducer.js b/app/jsx/assignments/GradeSummary/students/studentsReducer.js new file mode 100644 index 00000000000..bbf5c8d9d5d --- /dev/null +++ b/app/jsx/assignments/GradeSummary/students/studentsReducer.js @@ -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 . + */ + +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 +}) diff --git a/app/stylesheets/bundles/assignment_grade_summary.scss b/app/stylesheets/bundles/assignment_grade_summary.scss index 2c033530802..73b7715c180 100644 --- a/app/stylesheets/bundles/assignment_grade_summary.scss +++ b/app/stylesheets/bundles/assignment_grade_summary.scss @@ -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 */ diff --git a/spec/apis/v1/submissions_api_spec.rb b/spec/apis/v1/submissions_api_spec.rb index 672f06822ec..f11f1849237 100644 --- a/spec/apis/v1/submissions_api_spec.rb +++ b/spec/apis/v1/submissions_api_spec.rb @@ -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 diff --git a/spec/javascripts/jsx/assignments/GradeSummary/components/GradesGrid/GridRowSpec.js b/spec/javascripts/jsx/assignments/GradeSummary/components/GradesGrid/GridRowSpec.js new file mode 100644 index 00000000000..a0d810a5cbc --- /dev/null +++ b/spec/javascripts/jsx/assignments/GradeSummary/components/GradesGrid/GridRowSpec.js @@ -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 . + */ + +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( + + + + +
+ ) + } + + 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(), '–') + }) +}) diff --git a/spec/javascripts/jsx/assignments/GradeSummary/components/GradesGrid/GridSpec.js b/spec/javascripts/jsx/assignments/GradeSummary/components/GradesGrid/GridSpec.js new file mode 100644 index 00000000000..3977af6bd4e --- /dev/null +++ b/spec/javascripts/jsx/assignments/GradeSummary/components/GradesGrid/GridSpec.js @@ -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 . + */ + +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() + } + + 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]) + }) +}) diff --git a/spec/javascripts/jsx/assignments/GradeSummary/components/GradesGridSpec.js b/spec/javascripts/jsx/assignments/GradeSummary/components/GradesGridSpec.js new file mode 100644 index 00000000000..3251bd1f6b2 --- /dev/null +++ b/spec/javascripts/jsx/assignments/GradeSummary/components/GradesGridSpec.js @@ -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 . + */ + +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() + } + + 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']) + }) +}) diff --git a/spec/javascripts/jsx/assignments/GradeSummary/components/LayoutSpec.js b/spec/javascripts/jsx/assignments/GradeSummary/components/LayoutSpec.js index 7ab5f96f47d..a63f1de9b47 100644 --- a/spec/javascripts/jsx/assignments/GradeSummary/components/LayoutSpec.js +++ b/spec/javascripts/jsx/assignments/GradeSummary/components/LayoutSpec.js @@ -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) + }) + }) }) diff --git a/spec/javascripts/jsx/assignments/GradeSummary/getEnvSpec.js b/spec/javascripts/jsx/assignments/GradeSummary/getEnvSpec.js index 9dc66674159..e6b4bfd1cb5 100644 --- a/spec/javascripts/jsx/assignments/GradeSummary/getEnvSpec.js +++ b/spec/javascripts/jsx/assignments/GradeSummary/getEnvSpec.js @@ -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: { diff --git a/spec/javascripts/jsx/assignments/GradeSummary/grades/GradeActionsSpec.js b/spec/javascripts/jsx/assignments/GradeSummary/grades/GradeActionsSpec.js new file mode 100644 index 00000000000..a6a43ea9283 --- /dev/null +++ b/spec/javascripts/jsx/assignments/GradeSummary/grades/GradeActionsSpec.js @@ -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 . + */ + +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]) + }) + }) +}) diff --git a/spec/javascripts/jsx/assignments/GradeSummary/grades/gradesReducerSpec.js b/spec/javascripts/jsx/assignments/GradeSummary/grades/gradesReducerSpec.js new file mode 100644 index 00000000000..8b70e637d95 --- /dev/null +++ b/spec/javascripts/jsx/assignments/GradeSummary/grades/gradesReducerSpec.js @@ -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 . + */ + +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]) + }) + }) +}) diff --git a/spec/javascripts/jsx/assignments/GradeSummary/students/StudentActionsSpec.js b/spec/javascripts/jsx/assignments/GradeSummary/students/StudentActionsSpec.js new file mode 100644 index 00000000000..e8a4064658d --- /dev/null +++ b/spec/javascripts/jsx/assignments/GradeSummary/students/StudentActionsSpec.js @@ -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 . + */ + +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) + }) + }) +}) diff --git a/spec/javascripts/jsx/assignments/GradeSummary/students/StudentsApiSpec.js b/spec/javascripts/jsx/assignments/GradeSummary/students/StudentsApiSpec.js new file mode 100644 index 00000000000..4fee429e2f0 --- /dev/null +++ b/spec/javascripts/jsx/assignments/GradeSummary/students/StudentsApiSpec.js @@ -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 . + */ + +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) + } + }) + }) +}) diff --git a/spec/javascripts/jsx/assignments/GradeSummary/students/studentsReducerSpec.js b/spec/javascripts/jsx/assignments/GradeSummary/students/studentsReducerSpec.js new file mode 100644 index 00000000000..e9367a3f558 --- /dev/null +++ b/spec/javascripts/jsx/assignments/GradeSummary/students/studentsReducerSpec.js @@ -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 . + */ + +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) + }) + }) +})