enhance individual gradebook student section

this is an iterative commit for the enhanced individual gradebook work
currently under feature flag. this commit modifies the graphql queries
and sets the template for the student section for the rework

refs EVAL-3127
flag=individual_gradebook_enhancements

test plan:
- none

Change-Id: I09afeecbf1ed7d3091f63ce84946ccea41382bbb
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/318319
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Aaron Shafovaloff <ashafovaloff@instructure.com>
QA-Review: Aaron Shafovaloff <ashafovaloff@instructure.com>
Product-Review: Aaron Shafovaloff <ashafovaloff@instructure.com>
This commit is contained in:
Chris Soto 2023-05-16 15:08:41 -06:00 committed by Aaron Shafovaloff
parent 7746f95086
commit aca8e7fd3e
8 changed files with 317 additions and 46 deletions

View File

@ -390,4 +390,6 @@ module Interfaces::SubmissionInterface
def assigned_assessments
load_association(:assigned_assessments)
end
field :assignment_id, ID, null: false
end

View File

@ -34,6 +34,7 @@ describe Types::SubmissionType do
expect(submission_type.resolve("user { _id }")).to eq @student.id.to_s
expect(submission_type.resolve("excused")).to be false
expect(submission_type.resolve("assignment { _id }")).to eq @assignment.id.to_s
expect(submission_type.resolve("assignmentId")).to eq @assignment.id.to_s
end
it "requires read permission" do

View File

@ -21,7 +21,7 @@ import gql from 'graphql-tag'
export const GRADEBOOK_QUERY = gql`
query GradebookQuery($courseId: ID!) {
course(id: $courseId) {
enrollmentsConnection(filter: {types: StudentEnrollment}) {
enrollmentsConnection(filter: {types: [StudentEnrollment, StudentViewEnrollment]}) {
nodes {
user {
id: _id
@ -35,16 +35,67 @@ export const GRADEBOOK_QUERY = gql`
grade
id: _id
score
assignment {
id: _id
assignmentId
}
}
}
assignmentsConnection {
assignmentGroupsConnection {
nodes {
id: _id
name
groupWeight
rules {
dropHighest
dropLowest
}
state
position
assignmentsConnection {
nodes {
pointsPossible
id: _id
name
}
}
}
}
}
}
`
export const GRADEBOOK_STUDENT_QUERY = gql`
query GradebookStudentQuery($courseId: ID!, $userIds: [ID!]) {
course(id: $courseId) {
usersConnection(
filter: {
enrollmentTypes: [StudentEnrollment, StudentViewEnrollment]
enrollmentStates: active
userIds: $userIds
}
) {
nodes {
enrollments {
id: _id
grades {
unpostedCurrentGrade
unpostedCurrentScore
unpostedFinalGrade
unpostedFinalScore
}
section {
id: _id
name
}
}
loginId
name
}
}
submissionsConnection(studentIds: $userIds) {
nodes {
grade
id: _id
score
assignmentId
}
}
}

View File

@ -19,12 +19,12 @@
import React from 'react'
import {View} from '@instructure/ui-view'
import {useScope as useI18nScope} from '@canvas/i18n'
import {AssignmentConnectionResponse, SubmissionConnectionResponse} from '../types'
import {AssignmentConnection, SubmissionConnectionResponse} from '../types'
const I18n = useI18nScope('enhanced_individual_gradebook')
type Props = {
assignment?: AssignmentConnectionResponse
assignment?: AssignmentConnection
submissions?: SubmissionConnectionResponse[]
}

View File

@ -19,18 +19,18 @@
import React, {useEffect, useState} from 'react'
import {useScope as useI18nScope} from '@canvas/i18n'
import {AssignmentConnectionResponse, UserConnectionResponse} from '../types'
import {AssignmentConnection, UserConnectionResponse} from '../types'
import {View} from '@instructure/ui-view'
const I18n = useI18nScope('enhanced_individual_gradebook')
type Props = {
assignments: AssignmentConnectionResponse[]
assignments: AssignmentConnection[]
students: UserConnectionResponse[]
selectedStudentId?: string | null
selectedAssignmentId?: string | null
onStudentChange: (student?: UserConnectionResponse) => void
onAssignmentChange: (assignment?: AssignmentConnectionResponse) => void
onStudentChange: (studentId?: string) => void
onAssignmentChange: (assignment?: AssignmentConnection) => void
}
// TODO: might want a map for quicker lookups
@ -41,7 +41,7 @@ type DropDownOption<T> = {
}
type StudentDropdownOption = DropDownOption<UserConnectionResponse>[]
type AssignmentDropdownOption = DropDownOption<AssignmentConnectionResponse>[]
type AssignmentDropdownOption = DropDownOption<AssignmentConnection>[]
const DEFAULT_STUDENT_DROPDOWN_TEXT = I18n.t('No Student Selected')
const DEFAULT_ASSIGNMENT_DROPDOWN_TEXT = I18n.t('No Assignment Selected')
@ -106,7 +106,7 @@ export default function ContentSelection({
const selectedIndex = (event ? event.target.selectedIndex : newIndex) ?? 0
setSelectedStudentIndex(selectedIndex)
const selectedStudent = studentDropdownOptions[selectedIndex]?.data
onStudentChange(selectedStudent)
onStudentChange(selectedStudent?.id)
}
const handleChangeAssignment = (

View File

@ -22,13 +22,14 @@ import {useSearchParams} from 'react-router-dom'
import {useScope as useI18nScope} from '@canvas/i18n'
import {View} from '@instructure/ui-view'
import {AssignmentGroupCriteriaMap} from '../../../shared/grading/grading.d'
import AssignmentInformation from './AssignmentInformation'
import ContentSelection from './ContentSelection'
import GlobalSettings from './GlobalSettings'
import GradingResults from './GradingResults'
import StudentInformation from './StudentInformation'
import {
AssignmentConnectionResponse,
AssignmentConnection,
GradebookQueryResponse,
SubmissionConnectionResponse,
UserConnectionResponse,
@ -43,15 +44,20 @@ const ASSIGNMENT_SEARCH_PARAM = 'assignment'
export default function EnhancedIndividualGradebook() {
const [submissions, setSubmissions] = useState<SubmissionConnectionResponse[]>([])
const [students, setStudents] = useState<UserConnectionResponse[]>([])
const [assignments, setAssignments] = useState<AssignmentConnectionResponse[]>([])
const [assignments, setAssignments] = useState<AssignmentConnection[]>([])
const [selectedStudent, setSelectedStudent] = useState<UserConnectionResponse>()
const [selectedAssignment, setSelectedAssignment] = useState<AssignmentConnectionResponse>()
const [selectedAssignment, setSelectedAssignment] = useState<AssignmentConnection>()
const [selectedSubmissions, setSelectedSubmissions] = useState<SubmissionConnectionResponse[]>([])
const courseId = ENV.GRADEBOOK_OPTIONS?.context_id // TODO: get from somewhere else?
const courseId = ENV.GRADEBOOK_OPTIONS?.context_id || '' // TODO: get from somewhere else?
const [searchParams, setSearhParams] = useSearchParams()
const selectedStudentId = searchParams.get(STUDENT_SEARCH_PARAM)
const studentIdQueryParam = searchParams.get(STUDENT_SEARCH_PARAM)
const [selectedStudentId, setSelectedStudentId] = useState<string | null | undefined>(
studentIdQueryParam
)
const [assignmentGroupMap, setAssignmentGroupMap] = useState<AssignmentGroupCriteriaMap>({})
const selectedAssignmentId = searchParams.get(ASSIGNMENT_SEARCH_PARAM)
const {data, error} = useQuery<GradebookQueryResponse>(GRADEBOOK_QUERY, {
@ -66,9 +72,43 @@ export default function EnhancedIndividualGradebook() {
}
if (data?.course) {
const {assignmentsConnection, enrollmentsConnection, submissionsConnection} = data.course
const {assignmentGroupsConnection, enrollmentsConnection, submissionsConnection} = data.course
setAssignments(assignmentsConnection.nodes)
const {assignments, assignmentGroupMap} = assignmentGroupsConnection.nodes.reduce(
(prev, curr) => {
prev.assignments.push(...curr.assignmentsConnection.nodes)
prev.assignmentGroupMap[curr.id] = {
name: curr.name,
assignments: curr.assignmentsConnection.nodes.map(assignment => {
return {
id: assignment.id,
name: assignment.name,
points_possible: assignment.pointsPossible,
submission_types: assignment.submissionTypes,
anonymize_students: assignment.anonymizeStudents,
omit_from_final_grade: assignment.omitFromFinalGrade,
workflow_state: assignment.workflowState,
}
}),
group_weight: curr.groupWeight,
rules: curr.rules,
id: curr.id,
position: curr.position,
integration_data: {},
sis_source_id: null,
}
return prev
},
{
assignments: [] as AssignmentConnection[],
assignmentGroupMap: {} as AssignmentGroupCriteriaMap,
}
)
setAssignmentGroupMap(assignmentGroupMap)
setAssignments(assignments)
setSubmissions(submissionsConnection.nodes)
const studentEnrollments = enrollmentsConnection.nodes.map(enrollment => enrollment.user)
@ -79,17 +119,17 @@ export default function EnhancedIndividualGradebook() {
}
}, [data, error])
const handleStudentChange = (student?: UserConnectionResponse) => {
setSelectedStudent(student)
if (student) {
searchParams.set(STUDENT_SEARCH_PARAM, student?.id)
const handleStudentChange = (studentId?: string) => {
setSelectedStudentId(studentId)
if (studentId) {
searchParams.set(STUDENT_SEARCH_PARAM, studentId)
setSearhParams(searchParams)
} else {
searchParams.delete(STUDENT_SEARCH_PARAM)
}
}
const handleAssignmentChange = (assignment?: AssignmentConnectionResponse) => {
const handleAssignmentChange = (assignment?: AssignmentConnection) => {
setSelectedAssignment(assignment)
setSelectedSubmissions(submissions.filter(s => s.assignment.id === assignment?.id))
if (assignment) {
@ -130,7 +170,11 @@ export default function EnhancedIndividualGradebook() {
<div className="hr" style={{margin: 10, padding: 10, borderBottom: '1px solid #eee'}} />
<StudentInformation student={selectedStudent} />
<StudentInformation
courseId={courseId}
studentId={selectedStudentId}
assignmentGroupMap={assignmentGroupMap}
/>
<div className="hr" style={{margin: 10, padding: 10, borderBottom: '1px solid #eee'}} />

View File

@ -16,20 +16,49 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react'
import React, {useEffect, useState} from 'react'
import {useQuery} from 'react-apollo'
import {View} from '@instructure/ui-view'
import CourseGradeCalculator from '@canvas/grading/CourseGradeCalculator'
import {useScope as useI18nScope} from '@canvas/i18n'
import {UserConnectionResponse} from '../types'
import {
GradebookStudentDetails,
GradebookStudentQueryResponse,
GradebookUserSubmissionDetails,
} from '../types'
import {GRADEBOOK_STUDENT_QUERY} from '../queries/Queries'
import {AssignmentGroupCriteriaMap, SubmissionGradeCriteria} from '@canvas/grading/grading'
const I18n = useI18nScope('enhanced_individual_gradebook')
type Props = {
student?: UserConnectionResponse
courseId: string
studentId?: string | null
assignmentGroupMap: AssignmentGroupCriteriaMap
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export default function StudentInformation({student}: Props) {
export default function StudentInformation({courseId, studentId, assignmentGroupMap}: Props) {
const [selectedStudent, setSelectedStudent] = useState<GradebookStudentDetails>()
const [studentSubmissions, setStudentSubmissions] = useState<GradebookUserSubmissionDetails[]>([])
const {data, error} = useQuery<GradebookStudentQueryResponse>(GRADEBOOK_STUDENT_QUERY, {
variables: {courseId, userIds: studentId},
fetchPolicy: 'cache-and-network',
skip: !studentId,
})
useEffect(() => {
if (error) {
// TODO: handle error
}
if (data?.course) {
setSelectedStudent(data.course.usersConnection.nodes[0])
setStudentSubmissions(data.course.submissionsConnection.nodes)
}
}, [data, error])
if (!selectedStudent) {
return (
<View as="div">
<View as="div" className="row-fluid">
@ -44,4 +73,86 @@ export default function StudentInformation({student}: Props) {
</View>
</View>
)
}
const submissions: SubmissionGradeCriteria[] = studentSubmissions.map(submission => {
return {
assignment_id: submission.assignmentId,
excused: false,
grade: submission.grade,
score: submission.score,
workflow_state: 'graded',
id: submission.id,
}
})
CourseGradeCalculator.calculate(submissions, assignmentGroupMap, 'points', true)
return (
<div id="student_information">
<div className="row-fluid">
<div className="span4">
<h2>Student Information</h2>
</div>
<div className="span8">
<h3 className="student_selection">
<a href="studentUrl"> {selectedStudent.name}</a>
</h3>
<div>
<strong>
Secondary ID:
<span className="secondary_id"> {selectedStudent.loginId}</span>
</strong>
</div>
<div>
<strong>
Sections:{' '}
{selectedStudent.enrollments.map(enrollment => enrollment.section.name).join(', ')}
</strong>
</div>
<h4>Grades</h4>
<div className="ic-Table-responsive-x-scroll">
<table className="ic-Table">
<thead>
<tr>
{/* {{#if subtotal_by_period}}
<th scope="col">{{#t}}Grading Period{</th>
{{else}}
<th scope="col">{{#t}}Assignment Group{</th>
{{/if}} */}
<th scope="col">Assignment Group</th>
<th scope="col">Grade</th>
<th scope="col">Letter Grade</th>
<th scope="col">% of Grade</th>
</tr>
</thead>
<tbody>
{/* {{#each assignment_subtotal in assignment_subtotals}}
{{
assignment-subtotal-grades
subtotal=assignment_subtotal
student=selectedStudent
weightingScheme=weightingScheme
gradingStandard=ENV.GRADEBOOK_OPTIONS.grading_standard
}}
{{/each}} */}
</tbody>
</table>
</div>
<h3>
Final Grade:
<span className="total-grade">
&nbsp;{selectedStudent.enrollments[0].grades.unpostedCurrentScore}% (
{studentSubmissions.reduce((a, b) => a + b.score ?? 0, 0)} / )
</span>
</h3>
{/* {{partial "student_information/assignment_subtotals"}} */}
</div>
</div>
</div>
)
}

View File

@ -16,6 +16,8 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {WorkflowState} from '../../../api.d'
/**
* Temporarily generate types for GraphQL queries until we have automated generation setup
*/
@ -34,9 +36,30 @@ export type UserConnectionResponse = {
sortableName: string
}
export type AssignmentConnectionResponse = {
export type AssignmentConnection = {
id: string
name: string
pointsPossible: number
submissionTypes: string[]
anonymizeStudents: boolean
omitFromFinalGrade: boolean
workflowState: WorkflowState
}
export type AssignmentGroupConnection = {
id: string
name: string
groupWeight: number
rules: {
drop_lowest?: number
drop_highest?: number
never_drop?: string[]
}
state: string
position: number
assignmentsConnection: {
nodes: AssignmentConnection[]
}
}
export type SubmissionConnectionResponse = {
@ -49,6 +72,13 @@ export type SubmissionConnectionResponse = {
grade: string
}
export type EnrollmentGrades = {
unpostedCurrentGrade: number
unpostedCurrentScore: number
unpostedFinalGrade: number
unpostedFinalScore: number
}
export type GradebookQueryResponse = {
course: {
enrollmentsConnection: {
@ -59,8 +89,40 @@ export type GradebookQueryResponse = {
submissionsConnection: {
nodes: SubmissionConnectionResponse[]
}
assignmentsConnection: {
nodes: AssignmentConnectionResponse[]
assignmentGroupsConnection: {
nodes: AssignmentGroupConnection[]
}
}
}
export type GradebookStudentDetails = {
enrollments: {
id: string
grades: EnrollmentGrades
section: {
id: string
name: string
}
}[]
loginId: string
name: string
}
export type GradebookUserSubmissionDetails = {
grade: string
id: string
score: number
assignmentId: string
workflowState: string
}
export type GradebookStudentQueryResponse = {
course: {
usersConnection: {
nodes: GradebookStudentDetails[]
}
submissionsConnection: {
nodes: GradebookUserSubmissionDetails[]
}
}
}