Extract final grade override data loading

Test plan:
  - Open gradebook settings and enable final grade override
    - Open gradebook and verify that final grade override is visible
    - Override final grade for a student
  - Open gradebook settings and disable final grade override
    - Verify that final grade override is not visible

Refs EVAL-1934

flag=none

Change-Id: If3a5c26275c4d237fbccf8b924e49025b795f06e
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/305599
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Cameron Ray <cameron.ray@instructure.com>
Reviewed-by: Kai Bjorkman <kbjorkman@instructure.com>
QA-Review: Cameron Ray <cameron.ray@instructure.com>
Product-Review: Cameron Ray <cameron.ray@instructure.com>
This commit is contained in:
Aaron Shafovaloff 2022-11-17 09:48:04 -07:00
parent b1f865bd0e
commit 4ac41dda0a
15 changed files with 236 additions and 183 deletions

View File

@ -68,58 +68,39 @@ QUnit.module('Gradebook CourseSettings', suiteHooks => {
QUnit.module('#handleUpdated()', hooks => {
hooks.beforeEach(() => {
buildGradebook()
sinon.stub(gradebook.finalGradeOverrides, 'loadFinalGradeOverrides')
sinon.stub(gradebook, 'updateColumns')
})
QUnit.module('when "allow final grade override" becomes enabled', contextHooks => {
contextHooks.beforeEach(() => {
gradebook.courseSettings.setAllowFinalGradeOverride(false)
gradebook.courseSettings.handleUpdated({
allowFinalGradeOverride: true,
})
gradebook.courseSettings.handleUpdated(
{
allowFinalGradeOverride: true,
},
() => {}
)
})
test('updates columns in the Gradebook grid', () => {
strictEqual(gradebook.updateColumns.callCount, 1)
})
test('loads final grade overrides', () => {
strictEqual(gradebook.finalGradeOverrides.loadFinalGradeOverrides.callCount, 1)
})
})
QUnit.module('when "allow final grade override" becomes disabled', contextHooks => {
contextHooks.beforeEach(() => {
gradebook.courseSettings.setAllowFinalGradeOverride(true)
gradebook.courseSettings.handleUpdated({
allowFinalGradeOverride: false,
})
gradebook.courseSettings.handleUpdated(
{
allowFinalGradeOverride: false,
},
() => {}
)
})
test('updates columns in the Gradebook grid', () => {
strictEqual(gradebook.updateColumns.callCount, 1)
})
test('does not load final grade overrides', () => {
strictEqual(gradebook.finalGradeOverrides.loadFinalGradeOverrides.callCount, 0)
})
})
QUnit.module('when "allow final grade override" is not changed', contextHooks => {
contextHooks.beforeEach(() => {
gradebook.courseSettings.setAllowFinalGradeOverride(true)
gradebook.courseSettings.handleUpdated({})
})
test('does not update columns in the Gradebook grid', () => {
strictEqual(gradebook.updateColumns.callCount, 0)
})
test('does not load final grade overrides', () => {
strictEqual(gradebook.finalGradeOverrides.loadFinalGradeOverrides.callCount, 0)
})
})
})
})

View File

@ -273,36 +273,6 @@ QUnit.module('Gradebook > DataLoader > StudentContentDataLoader', suiteHooks =>
})
})
QUnit.module('loading final grade overrides', () => {
test('optionally requests final grade overrides', async () => {
await load()
strictEqual(FinalGradeOverrideApi.getFinalGradeOverrides.callCount, 1)
})
test('optionally does not request final grade overrides', async () => {
gradebook.courseSettings.setAllowFinalGradeOverride(false)
await load()
strictEqual(FinalGradeOverrideApi.getFinalGradeOverrides.callCount, 0)
})
test('uses the given course id when loading final grade overrides', async () => {
await load()
const [courseId] = FinalGradeOverrideApi.getFinalGradeOverrides.lastCall.args
strictEqual(courseId, '1201')
})
test('updates Gradebook when the final grade overrides have loaded', async () => {
await load()
strictEqual(gradebook.finalGradeOverrides.setGrades.callCount, 1)
})
test('updates Gradebook with the loaded final grade overrides', async () => {
await load()
const [finalGradeOverrides] = gradebook.finalGradeOverrides.setGrades.lastCall.args
deepEqual(finalGradeOverrides, exampleData.finalGradeOverrides)
})
})
QUnit.module('when submissions return before related students', contextHooks => {
let events

View File

@ -42,7 +42,7 @@ export default class CourseSettings {
this._settings.allowFinalGradeOverride = allow
}
handleUpdated(settings: Settings) {
handleUpdated(settings: Settings, fetchFinalGradeOverrides: () => Promise<void>) {
const previousSettings = {...this._settings}
this._settings = {
...this._settings,
@ -53,7 +53,7 @@ export default class CourseSettings {
this._gradebook.updateColumns()
if (this._settings.allowFinalGradeOverride) {
this._gradebook.finalGradeOverrides?.loadFinalGradeOverrides()
fetchFinalGradeOverrides()
}
}
}

View File

@ -21,11 +21,20 @@ import {useScope as useI18nScope} from '@canvas/i18n'
import type Gradebook from '../Gradebook'
import type {RequestDispatch} from '@canvas/network'
import type PerformanceControls from '../PerformanceControls'
import type {Student} from '../../../../../api.d'
import {showFlashAlert} from '@canvas/alerts/react/FlashAlert'
const I18n = useI18nScope('gradebook')
type Options = {
dispatch: RequestDispatch
gradebook: Gradebook
submissionsChunkSize: number
courseId: string
submissionsPerPage: number
}
const submissionsParams = {
exclude_response_fields: ['preview_url'],
grouped: 1,
@ -75,13 +84,7 @@ function flashSubmissionLoadError(): void {
function ignoreFailure() {}
function getStudentsChunk(
courseId: string,
studentIds: string[],
options: {
dispatch: RequestDispatch
}
) {
function getStudentsChunk(courseId: string, studentIds: string[], options: Options) {
const url = `/api/v1/courses/${courseId}/users`
const params = {
enrollment_state: ['active', 'completed', 'inactive', 'invited'],
@ -91,17 +94,22 @@ function getStudentsChunk(
user_ids: studentIds,
}
return options.dispatch.getJSON(url, params)
return options.dispatch.getJSON<Student[]>(url, params)
}
function getSubmissionsForStudents(options, studentIds, allEnqueued, dispatch) {
function getSubmissionsForStudents(
options: Options,
studentIds: string[],
allEnqueued,
dispatch: RequestDispatch
) {
return new Promise((resolve, reject) => {
const {courseId, submissionsPerPage} = options
const url = `/api/v1/courses/${courseId}/students/submissions`
const params = {...submissionsParams, student_ids: studentIds, per_page: submissionsPerPage}
dispatch
.getDepaginated(url, params, undefined, allEnqueued)
.getDepaginated<Student[]>(url, params, undefined, allEnqueued)
.then(resolve)
.catch(() => {
flashSubmissionLoadError()
@ -110,15 +118,7 @@ function getSubmissionsForStudents(options, studentIds, allEnqueued, dispatch) {
})
}
function getContentForStudentIdChunk(
studentIds: string[],
options: {
dispatch: RequestDispatch
gradebook: Gradebook
submissionsChunkSize: number
courseId: string
}
) {
function getContentForStudentIdChunk(studentIds: string[], options: Options) {
const {dispatch, gradebook, submissionsChunkSize} = options
let resolveEnqueued
@ -230,15 +230,8 @@ export default class StudentContentDataLoader {
getNextChunk()
})
.then(() => {
const {courseSettings, finalGradeOverrides} = gradebook
let finalGradeOverridesRequest
if (courseSettings.allowFinalGradeOverride && finalGradeOverrides) {
finalGradeOverridesRequest = finalGradeOverrides.loadFinalGradeOverrides()
}
// wait for all student, submission, and final grade override requests to return
return Promise.all([...studentRequests, ...submissionRequests, finalGradeOverridesRequest])
// wait for all student, submission requests to return
return Promise.all([...studentRequests, ...submissionRequests])
})
.then(() => {
gradebook.updateStudentsLoaded(true)

View File

@ -428,72 +428,4 @@ describe('Gradebook FinalGradeOverrides', () => {
})
})
})
describe('#loadFinalGradeOverrides()', () => {
beforeEach(() => {
grades = {
1101: {
courseGrade: {
percentage: 88.1,
},
},
1102: {
courseGrade: {
percentage: 91.1,
},
},
}
sinon
.stub(FinalGradeOverrideApi, 'getFinalGradeOverrides')
.returns(Promise.resolve({finalGradeOverrides: grades}))
})
afterEach(() => {
FinalGradeOverrideApi.getFinalGradeOverrides.restore()
})
it('optionally requests final grade overrides', async () => {
await finalGradeOverrides.loadFinalGradeOverrides()
expect(FinalGradeOverrideApi.getFinalGradeOverrides.callCount).toEqual(1)
})
it('uses the course id from Gradebook when loading final grade overrides', async () => {
await finalGradeOverrides.loadFinalGradeOverrides()
const [courseId] = FinalGradeOverrideApi.getFinalGradeOverrides.lastCall.args
expect(courseId).toEqual('1201')
})
it('stores the given final grade overrides in the Gradebook', async () => {
await finalGradeOverrides.loadFinalGradeOverrides()
expect(finalGradeOverrides.getGradeForUser('1101')).toEqual(grades[1101].courseGrade)
})
it('updates row cells for each related student', async () => {
await finalGradeOverrides.loadFinalGradeOverrides()
expect(gradebook.gradebookGrid.updateRowCell.callCount).toEqual(2)
})
it('includes the user id when updating column cells', async () => {
await finalGradeOverrides.loadFinalGradeOverrides()
const calls = [0, 1].map(index => gradebook.gradebookGrid.updateRowCell.getCall(index))
const studentIds = calls.map(call => call.args[0])
expect(studentIds).toEqual(['1101', '1102'])
})
it('includes the column id when updating column cells', async () => {
await finalGradeOverrides.loadFinalGradeOverrides()
const calls = [0, 1].map(index => gradebook.gradebookGrid.updateRowCell.getCall(index))
const columnIds = calls.map(call => call.args[1])
expect(columnIds).toEqual(['total_grade_override', 'total_grade_override'])
})
it('updates row cells after storing final grade overrides', async () => {
gradebook.gradebookGrid.updateRowCell.callsFake(() => {
// final grade overrides will have already been updated by this time
expect(finalGradeOverrides.getGradeForUser('1101')).toEqual(grades[1101].courseGrade)
})
await finalGradeOverrides.loadFinalGradeOverrides()
})
})
})

View File

@ -18,10 +18,7 @@
import {useScope as useI18nScope} from '@canvas/i18n'
import {showFlashAlert} from '@canvas/alerts/react/FlashAlert'
import {
getFinalGradeOverrides,
updateFinalGradeOverride,
} from '@canvas/grading/FinalGradeOverrideApi'
import {updateFinalGradeOverride} from '@canvas/grading/FinalGradeOverrideApi'
import FinalGradeOverrideDatastore from './FinalGradeOverrideDatastore'
import type Gradebook from '../Gradebook'
@ -104,12 +101,4 @@ export default class FinalGradeOverrides {
})
}
}
loadFinalGradeOverrides() {
return getFinalGradeOverrides(this._gradebook.course.id).then(({finalGradeOverrides}) => {
if (finalGradeOverrides) {
this.setGrades(finalGradeOverrides)
}
})
}
}

View File

@ -73,7 +73,7 @@ import type {
PendingGradeInfo,
SubmissionFilterValue,
} from './gradebook.d'
import type {CamelizedGradingPeriodSet} from '@canvas/grading/grading.d'
import type {CamelizedGradingPeriodSet, FinalGradeOverrideMap} from '@canvas/grading/grading.d'
import type {
GridColumn,
GridData,
@ -232,9 +232,11 @@ export type GradebookProps = {
currentUserId: string
customColumns: CustomColumn[]
dispatch: RequestDispatch
fetchFinalGradeOverrides: () => Promise<void>
fetchGradingPeriodAssignments: () => Promise<GradingPeriodAssignmentMap>
fetchStudentIds: () => Promise<string[]>
filterNavNode: HTMLElement
finalGradeOverrides: FinalGradeOverrideMap
flashAlerts: FlashAlertType[]
flashMessageContainer: HTMLElement
gradebookEnv: GradebookOptions
@ -2053,7 +2055,7 @@ class Gradebook extends React.Component<GradebookProps, GradebookState> {
return this.gradebookSettingsModalButton.current?.focus()
},
onCourseSettingsUpdated: settings => {
return this.courseSettings.handleUpdated(settings)
return this.courseSettings.handleUpdated(settings, this.props.fetchFinalGradeOverrides)
},
onLatePolicyUpdate: this.onLatePolicyUpdate,
postPolicies: this.postPolicies,
@ -4605,6 +4607,14 @@ class Gradebook extends React.Component<GradebookProps, GradebookState> {
)
}
// final grade overrides
if (
prevProps.finalGradeOverrides !== this.props.finalGradeOverrides &&
this.props.finalGradeOverrides
) {
this.finalGradeOverrides?.setGrades(this.props.finalGradeOverrides)
}
// modules
if (
prevProps.isModulesLoading !== this.props.isModulesLoading &&

View File

@ -68,6 +68,9 @@ export default function GradebookData(props: Props) {
const isCustomColumnsLoading = useStore(state => state.isCustomColumnsLoading)
const fetchCustomColumns = useStore(state => state.fetchCustomColumns)
const finalGradeOverrides = useStore(state => state.finalGradeOverrides)
const fetchFinalGradeOverrides = useStore(state => state.fetchFinalGradeOverrides)
const studentIds = useStore(state => state.studentIds, shallow)
const isStudentIdsLoading = useStore(state => state.isStudentIdsLoading)
const fetchStudentIds = useStore(state => state.fetchStudentIds)
@ -90,6 +93,7 @@ export default function GradebookData(props: Props) {
dispatch: dispatch.current,
performanceControls: performanceControls.current,
hasModules: props.gradebookEnv.has_modules,
allowFinalGradeOverride: props.gradebookEnv.course_settings.allow_final_grade_override,
})
initializeAppliedFilters(
props.gradebookEnv.settings.filter_rows_by || {},
@ -101,6 +105,7 @@ export default function GradebookData(props: Props) {
props.gradebookEnv.settings.filter_rows_by,
props.gradebookEnv.settings.filter_columns_by,
props.gradebookEnv.has_modules,
props.gradebookEnv.course_settings.allow_final_grade_override,
initializeAppliedFilters,
])
@ -112,16 +117,21 @@ export default function GradebookData(props: Props) {
if (props.gradebookEnv.has_modules) {
fetchModules()
}
if (props.gradebookEnv.course_settings.allow_final_grade_override) {
fetchFinalGradeOverrides()
}
fetchCustomColumns()
}, [
fetchFilters,
fetchModules,
fetchCustomColumns,
fetchFilters,
fetchFinalGradeOverrides,
fetchModules,
initializeStagedFilters,
props.gradebookEnv.course_settings.allow_final_grade_override,
props.gradebookEnv.enhanced_gradebook_filters,
props.gradebookEnv.has_modules,
initializeStagedFilters,
props.gradebookEnv.settings.filter_rows_by,
props.gradebookEnv.settings.filter_columns_by,
props.gradebookEnv.settings.filter_rows_by,
])
useEffect(() => {
@ -152,6 +162,7 @@ export default function GradebookData(props: Props) {
{...props}
appliedFilters={appliedFilters}
customColumns={customColumns}
fetchFinalGradeOverrides={fetchFinalGradeOverrides}
fetchGradingPeriodAssignments={fetchGradingPeriodAssignments}
fetchStudentIds={fetchStudentIds}
flashAlerts={flashMessages}
@ -162,6 +173,7 @@ export default function GradebookData(props: Props) {
isGradingPeriodAssignmentsLoading={isGradingPeriodAssignmentsLoading}
isModulesLoading={isModulesLoading}
isStudentIdsLoading={isStudentIdsLoading}
finalGradeOverrides={finalGradeOverrides}
modules={modules}
recentlyLoadedAssignmentGroups={recentlyLoadedAssignmentGroups}
studentIds={studentIds}

View File

@ -29,6 +29,9 @@ const defaultProps = {
gradebookEnv: {
context_id: '1',
enhanced_gradebook_filters: false,
course_settings: {
allow_final_grade_override: true,
},
settings: {
filter_rows_by: {
section_id: null,

View File

@ -0,0 +1,92 @@
/*
* Copyright (C) 2022 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute test and/or modify test 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 test 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 {NetworkFake} from '@canvas/network/NetworkFake/index'
import store from '../index'
describe('Gradebook > store > fetchFinalGradeOverrides', () => {
const url = '/courses/0/gradebook/final_grade_overrides'
let exampleData
let network
beforeEach(() => {
exampleData = {
finalGradeOverrides: {
'1': {
courseGrade: {
percentage: 88.1,
},
gradingPeriodGrades: {
'2': {
percentage: 90,
},
},
},
},
}
})
describe('#fetchFinalGradeOverrides()', () => {
beforeEach(() => {
network = new NetworkFake()
})
afterEach(() => {
network.restore()
})
function resolveRequest() {
const [request] = getRequests()
request.response.setJson({
final_grade_overrides: {
'1': {
course_grade: {
percentage: 88.1,
},
grading_period_grades: {
'2': {
percentage: 90,
},
},
},
},
})
request.response.send()
}
function getRequests() {
return network.getRequests(request => request.url === url)
}
test('sends the request', async () => {
store.getState().fetchFinalGradeOverrides()
await network.allRequestsReady()
const requests = getRequests()
expect(requests.length).toStrictEqual(1)
})
test('saves final grade overrides to the store', async () => {
const promise = store.getState().fetchFinalGradeOverrides()
await network.allRequestsReady()
resolveRequest()
await promise
expect(store.getState().finalGradeOverrides).toStrictEqual(exampleData.finalGradeOverrides)
})
})
})

View File

@ -0,0 +1,51 @@
/*
* Copyright (C) 2022 - 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 {GetState, SetState} from 'zustand'
import {getFinalGradeOverrides} from '@canvas/grading/FinalGradeOverrideApi'
import type {GradebookStore} from './index'
import type {FinalGradeOverrideMap} from '@canvas/grading/grading.d'
export type FinalGradeOverrideState = {
allowFinalGradeOverride: boolean
areFinalGradeOverridesLoaded: boolean
finalGradeOverrides: FinalGradeOverrideMap
fetchFinalGradeOverrides: () => Promise<void>
}
export default (
set: SetState<GradebookStore>,
get: GetState<GradebookStore>
): FinalGradeOverrideState => ({
allowFinalGradeOverride: false,
finalGradeOverrides: {},
areFinalGradeOverridesLoaded: false,
fetchFinalGradeOverrides: (): Promise<void> => {
return getFinalGradeOverrides(get().courseId).then(data => {
if (data?.finalGradeOverrides) {
set({
finalGradeOverrides: data?.finalGradeOverrides,
areFinalGradeOverridesLoaded: true,
})
}
})
},
})

View File

@ -22,6 +22,7 @@ import modules, {ModulesState} from './modulesState'
import students, {StudentsState} from './studentsState'
import assignments, {AssignmentsState} from './assignmentsState'
import customColumns, {CustomColumnsState} from './customColumnsState'
import finalGradeOverrides, {FinalGradeOverrideState} from './finalGradeOverrides'
import {RequestDispatch} from '@canvas/network'
import PerformanceControls from '../PerformanceControls'
import type {FlashMessage} from '../gradebook.d'
@ -44,7 +45,8 @@ export type GradebookStore = State &
FiltersState &
ModulesState &
StudentsState &
AssignmentsState
AssignmentsState &
FinalGradeOverrideState
const store = create<GradebookStore>((set, get) => ({
performanceControls: defaultPerformanceControls,
@ -64,6 +66,8 @@ const store = create<GradebookStore>((set, get) => ({
...students(set, get),
...assignments(set, get),
...finalGradeOverrides(set, get),
}))
export default store

View File

@ -19,13 +19,15 @@
import axios from '@canvas/axios'
import {camelize} from 'convert-case'
import {useScope as useI18nScope} from '@canvas/i18n'
import {createClient, gql} from '@canvas/apollo'
import {showFlashAlert} from '@canvas/alerts/react/FlashAlert'
import type {FinalGradeOverrideMap} from './grading.d'
const I18n = useI18nScope('finalGradeOverrideApi')
export function getFinalGradeOverrides(courseId: string) {
export function getFinalGradeOverrides(
courseId: string
): Promise<void | {finalGradeOverrides: FinalGradeOverrideMap}> {
const url = `/courses/${courseId}/gradebook/final_grade_overrides`
return axios
@ -36,7 +38,10 @@ export function getFinalGradeOverrides(courseId: string) {
for (const studentId in response.data.final_grade_overrides) {
const responseOverrides = response.data.final_grade_overrides[studentId]
const studentOverrides: {
courseGrade?: string
courseGrade?: {
percentage: number | null
schemeKey: string | null
}
gradingPeriodGrades?: {[gradingPeriodId: string]: string}
} = (data.finalGradeOverrides[studentId] = {})

View File

@ -220,3 +220,14 @@ export type CamelizedSubmissionWithOriginalityReport = CamelizedSubmission & {
turnitinData?: PlagiarismDataMap
vericiteData?: {provider: 'vericite'} & PlagiarismDataMap
}
export type FinalGradeOverride = {
courseGrade?: string
gradingPeriodGrades?: {
[gradingPeriodId: string]: string
}
}
export type FinalGradeOverrideMap = {
[userId: string]: FinalGradeOverride
}

View File

@ -130,9 +130,9 @@ export default class RequestDispatch {
return request.deferred.promise
}
getJSON(url: string, params?) {
getJSON<T>(url: string, params?) {
const request = {
deferred: deferPromise(),
deferred: deferPromise<T>(),
start: () => {},
active: false,
}