export current gradebook view

flag=enhanced_gradebook_filters
closes EVAL-2288

Test Plan:
    - Course w/ teacher, students, and graded assignments
    - Fill gradebook with recognizable data
    - Create Sections, Modules, and Student Groups to filter down the
    gradebook
    - Repeat all of the following steps with enhanced_gradebook_filters
    ON and OFF
    - Navigate to the gradebook and put any combination of filters On
    the gradebook
    - Navigate to the Export (ON) or Actions (OFF) menu and select
    'Export Current Gradebook View'
    - Ensure only the assignments and students currently shown in the
    filtered gradebook are exported along with all of the totals
    columns displayed
    - Navigate to the Export (ON) or Actions (OFF) menu and select
    'Export Entire Gradebook'
    - Ensure all assignments and students are exported regardless of
    filters applied to the gradebook WITH the totals columns
    - Create grading periods and associate it with the course
    - Navigate to the Export (ON) or Actions (OFF) menu and select
    'Export Entire Gradebook'
    - Ensure all assignments and students are exported regardless of
    filters applied to the gradebook WITHOUT the totals columns
    - Ensure the 'Export Current Gradebook View' option works the same
    with grading periods and also respects that filter option

Change-Id: Iad3f030c1c26fd770885550a0912d42b975bb648
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/288884
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
QA-Review: Aaron Shafovaloff <ashafovaloff@instructure.com>
Product-Review: Jody Sailor
Reviewed-by: Syed Hussain <shussain@instructure.com>
Reviewed-by: Spencer Olson <solson@instructure.com>
This commit is contained in:
Kai Bjorkman 2022-04-04 17:21:54 -06:00 committed by Aaron Shafovaloff
parent 9b3455f072
commit d3058972f9
10 changed files with 330 additions and 150 deletions

View File

@ -30,7 +30,9 @@ class GradebookCsvsController < ApplicationController
csv_options = {
include_sis_id: @context.grants_any_right?(@current_user, session, :read_sis, :manage_sis),
grading_period_id: params[:grading_period_id]
grading_period_id: params[:grading_period_id],
student_order: params[:student_order],
current_view: params[:current_view]
}
if params[:assignment_order]

View File

@ -90,7 +90,11 @@ class GradebookExporter
end
# remove duplicate enrollments for students enrolled in multiple sections
student_enrollments = student_enrollments.uniq(&:user_id)
student_enrollments = if @options[:current_view]
student_enrollments.select { |s| @options[:student_order].include?(s[:user_id]) }.uniq(&:user_id)
else
student_enrollments.uniq(&:user_id)
end
# TODO: Stop using the grade calculator and instead use the scores table entirely.
# This cannot be done until we are storing points values in the scores table, which
@ -105,8 +109,12 @@ class GradebookExporter
submissions = {}
calc.submissions.each { |s| submissions[[s.user_id, s.assignment_id]] = s }
assignments = if @options[:current_view]
calc.gradable_assignments.select { |a| @options[:assignment_order].include?(a[:id]) }
else
calc.gradable_assignments
end
assignments = select_in_grading_period(calc.gradable_assignments).to_a
Assignment.preload_unposted_anonymous_submissions(assignments)
ActiveRecord::Associations.preload(assignments, :assignment_group)
@ -390,6 +398,7 @@ class GradebookExporter
end
def show_totals?
return false if !@options[:current_view] && @course.grading_periods?
return true unless @course.grading_periods?
return true if @options[:grading_period_id].try(:to_i) != 0

View File

@ -25,6 +25,7 @@ import ActionMenu from 'ui/features/gradebook/react/default_gradebook/components
const workingMenuProps = () => ({
getAssignmentOrder() {},
getStudentOrder() {},
gradebookIsEditable: true,
contextAllowsGradebookUploads: true,
gradebookImportUrl: 'http://gradebookImportUrl',
@ -86,10 +87,17 @@ test('renders the Import menu item', () => {
equal(specificMenuItem.textContent, 'Import')
})
test('renders the Export menu item', () => {
test('renders the Export Current Gradebook View menu item', () => {
const specificMenuItem = document.querySelector('[role="menuitem"] [data-menu-id="export"]')
equal(specificMenuItem.textContent, 'Export')
equal(specificMenuItem.textContent, 'Export Current Gradebook View')
equal(specificMenuItem.parentElement.getAttribute('aria-disabled'), null)
})
test('renders the Export Entire Gradebook menu item', () => {
const specificMenuItem = document.querySelector('[role="menuitem"] [data-menu-id="export-all"]')
equal(specificMenuItem.textContent, 'Export Entire Gradebook')
equal(specificMenuItem.parentElement.getAttribute('aria-disabled'), null)
})
@ -117,7 +125,7 @@ test('renders no Post Grades feature menu item when disabled', () => {
strictEqual(specificMenuItem, null)
})
test('renders the Post Grades feature menu item when enabled', function() {
test('renders the Post Grades feature menu item when enabled', function () {
this.wrapper.unmount()
const props = workingMenuProps()
props.postGradesFeature.enabled = true
@ -131,7 +139,7 @@ test('renders the Post Grades feature menu item when enabled', function() {
equal(specificMenuItem.textContent, 'Sync to SIS')
})
test('renders the Post Grades feature menu item with label when sis handle is set', function() {
test('renders the Post Grades feature menu item with label when sis handle is set', function () {
this.wrapper.unmount()
const props = workingMenuProps()
props.postGradesFeature.enabled = true
@ -156,7 +164,7 @@ QUnit.module('ActionMenu - getExistingExport', {
}
})
test('returns an export hash with workflowState when progressId and attachment.id are present', function() {
test('returns an export hash with workflowState when progressId and attachment.id are present', function () {
const propsWithPreviousExport = {
...workingMenuProps(),
...previousExportProps()
@ -172,11 +180,11 @@ test('returns an export hash with workflowState when progressId and attachment.i
deepEqual(this.wrapper.instance().getExistingExport(), expectedExport)
})
test('returns undefined when lastExport is undefined', function() {
test('returns undefined when lastExport is undefined', function () {
equal(this.wrapper.instance().getExistingExport(), undefined)
})
test("returns undefined when lastExport's attachment is undefined", function() {
test("returns undefined when lastExport's attachment is undefined", function () {
const propsWithPreviousExport = {
...workingMenuProps(),
...previousExportProps()
@ -188,7 +196,7 @@ test("returns undefined when lastExport's attachment is undefined", function() {
equal(this.wrapper.instance().getExistingExport(), undefined)
})
test('returns undefined when lastExport is missing progressId', function() {
test('returns undefined when lastExport is missing progressId', function () {
const propsWithPreviousExport = {
...workingMenuProps(),
...previousExportProps()
@ -200,7 +208,7 @@ test('returns undefined when lastExport is missing progressId', function() {
equal(this.wrapper.instance().getExistingExport(), undefined)
})
test("returns undefined when lastExport's attachment is missing its id", function() {
test("returns undefined when lastExport's attachment is missing its id", function () {
const propsWithPreviousExport = {
...workingMenuProps(),
...previousExportProps()
@ -221,7 +229,7 @@ QUnit.module('ActionMenu - handleExport', {
})
}
return Promise.reject('Export failure reason')
return Promise.reject(new Error('Export failure reason'))
},
setup() {
@ -256,7 +264,7 @@ QUnit.module('ActionMenu - handleExport', {
}
})
test('clicking on the export menu option calls the handleExport function', function() {
test('clicking on the export menu option calls the handleExport function', function () {
this.spies.handleExport = sandbox.stub(ActionMenu.prototype, 'handleExport')
this.menuItem.click()
@ -264,7 +272,7 @@ test('clicking on the export menu option calls the handleExport function', funct
equal(this.spies.handleExport.callCount, 1)
})
test('shows a message to the user indicating the export is in progress', function() {
test('shows a message to the user indicating the export is in progress', function () {
const exportResult = this.getPromise('resolved')
this.spies.startExport.returns(exportResult)
this.spies.flashMessage = sandbox.stub(window.$, 'flashMessage')
@ -277,7 +285,7 @@ test('shows a message to the user indicating the export is in progress', functio
return exportResult
})
test('changes the "Export" menu item to indicate the export is in progress', function() {
test('changes the "Export" menu item to indicate the export is in progress', function () {
const exportResult = this.getPromise('resolved')
this.spies.startExport.returns(exportResult)
@ -298,7 +306,7 @@ test('changes the "Export" menu item to indicate the export is in progress', fun
return exportResult
})
test('starts the export using the GradebookExportManager instance', function() {
test('starts the export using the GradebookExportManager instance', function () {
const exportResult = this.getPromise('resolved')
this.spies.startExport.returns(exportResult)
@ -309,7 +317,7 @@ test('starts the export using the GradebookExportManager instance', function() {
return exportResult
})
test('passes the grading period to the GradebookExportManager', function() {
test('passes the grading period to the GradebookExportManager', function () {
const exportResult = this.getPromise('resolved')
this.spies.startExport.returns(exportResult)
@ -321,7 +329,7 @@ test('passes the grading period to the GradebookExportManager', function() {
return exportResult
})
test('on success, takes the user to the newly completed export', function() {
test('on success, takes the user to the newly completed export', function () {
const exportResult = this.getPromise('resolved')
this.spies.startExport.returns(exportResult)
@ -336,7 +344,7 @@ test('on success, takes the user to the newly completed export', function() {
})
})
test('on success, re-enables the "Export" menu item', function() {
test('on success, re-enables the "Export Current Gradebook View" and "Export Entire Gradbook" menu items', function () {
const exportResult = this.getPromise('resolved')
this.spies.startExport.returns(exportResult)
@ -350,12 +358,17 @@ test('on success, re-enables the "Export" menu item', function() {
this.menuItem = document.querySelector('[role="menuitem"] [data-menu-id="export"]')
equal(this.menuItem.textContent, 'Export')
equal(this.menuItem.textContent, 'Export Current Gradebook View')
equal(this.menuItem.parentElement.getAttribute('aria-disabled'), null)
this.menuItem = document.querySelector('[role="menuitem"] [data-menu-id="export-all"]')
equal(this.menuItem.textContent, 'Export Entire Gradebook')
equal(this.menuItem.parentElement.getAttribute('aria-disabled'), null)
})
})
test('on success, shows the "New Export" menu item', function() {
test('on success, shows the "New Export" menu item', function () {
const exportResult = this.getPromise('resolved')
this.spies.startExport.returns(exportResult)
@ -375,7 +388,7 @@ test('on success, shows the "New Export" menu item', function() {
})
})
test('on failure, shows a message to the user indicating the export failed', function() {
test('on failure, shows a message to the user indicating the export failed', function () {
const exportResult = this.getPromise('rejected')
this.spies.startExport.returns(exportResult)
this.spies.flashError = sandbox.stub(window.$, 'flashError')
@ -387,12 +400,12 @@ test('on failure, shows a message to the user indicating the export failed', fun
equal(this.spies.flashError.callCount, 1)
equal(
this.spies.flashError.getCall(0).args[0],
'Gradebook Export Failed: Export failure reason'
'Gradebook Export Failed: Error: Export failure reason'
)
})
})
test('on failure, renables the "Export" menu item', function() {
test('on failure, renables the "Export Current Gradebook View" and "Export Entire Gradbook" menu items', function () {
const exportResult = this.getPromise('rejected')
this.spies.startExport.returns(exportResult)
@ -406,7 +419,12 @@ test('on failure, renables the "Export" menu item', function() {
this.menuItem = document.querySelector('[role="menuitem"] [data-menu-id="export"]')
equal(this.menuItem.textContent, 'Export')
equal(this.menuItem.textContent, 'Export Current Gradebook View')
equal(this.menuItem.parentElement.getAttribute('aria-disabled'), null)
this.menuItem = document.querySelector('[role="menuitem"] [data-menu-id="export-all"]')
equal(this.menuItem.textContent, 'Export Entire Gradebook')
equal(this.menuItem.parentElement.getAttribute('aria-disabled'), null)
})
})
@ -428,7 +446,7 @@ QUnit.module('ActionMenu - handleImport', {
}
})
test('clicking on the import menu option calls the handleImport function', function() {
test('clicking on the import menu option calls the handleImport function', function () {
const handleImportSpy = sandbox.spy(ActionMenu.prototype, 'handleImport')
this.menuItem.click()
@ -436,7 +454,7 @@ test('clicking on the import menu option calls the handleImport function', funct
equal(handleImportSpy.callCount, 1)
})
test('it takes you to the new imports page', function() {
test('it takes you to the new imports page', function () {
this.menuItem.click()
equal(this.spies.gotoUrl.callCount, 1)
@ -448,7 +466,7 @@ QUnit.module('ActionMenu - disableImports', {
}
})
test('is called once when the component renders', function() {
test('is called once when the component renders', function () {
const disableImportsSpy = sandbox.spy(ActionMenu.prototype, 'disableImports')
this.wrapper = mount(<ActionMenu {...workingMenuProps()} />)
@ -457,12 +475,12 @@ test('is called once when the component renders', function() {
equal(disableImportsSpy.callCount, 1)
})
test('returns false when gradebook is editable and context allows gradebook uploads', function() {
test('returns false when gradebook is editable and context allows gradebook uploads', function () {
this.wrapper = mount(<ActionMenu {...workingMenuProps()} />)
strictEqual(this.wrapper.instance().disableImports(), false)
})
test('returns true when gradebook is not editable and context allows gradebook uploads', function() {
test('returns true when gradebook is not editable and context allows gradebook uploads', function () {
const newImportProps = {
...workingMenuProps(),
gradebookIsEditable: false
@ -472,7 +490,7 @@ test('returns true when gradebook is not editable and context allows gradebook u
strictEqual(this.wrapper.instance().disableImports(), true)
})
test('returns true when gradebook is editable but context does not allow gradebook uploads', function() {
test('returns true when gradebook is editable but context does not allow gradebook uploads', function () {
const newImportProps = {
...workingMenuProps(),
contextAllowsGradebookUploads: false
@ -492,7 +510,7 @@ QUnit.module('ActionMenu - lastExportFromProps', {
}
})
test('returns the lastExport hash if props have a completed last export', function() {
test('returns the lastExport hash if props have a completed last export', function () {
const propsWithPreviousExport = {
...workingMenuProps(),
...previousExportProps()
@ -503,11 +521,11 @@ test('returns the lastExport hash if props have a completed last export', functi
deepEqual(this.wrapper.instance().lastExportFromProps(), propsWithPreviousExport.lastExport)
})
test('returns undefined if props have no lastExport', function() {
test('returns undefined if props have no lastExport', function () {
equal(this.wrapper.instance().lastExportFromProps(), undefined)
})
test('returns undefined if props have a lastExport but it is not completed', function() {
test('returns undefined if props have a lastExport but it is not completed', function () {
const propsWithPreviousExport = {
...workingMenuProps(),
...previousExportProps()
@ -529,7 +547,7 @@ QUnit.module('ActionMenu - lastExportFromState', {
}
})
test('returns the previous export if state has a previousExport defined', function() {
test('returns the previous export if state has a previousExport defined', function () {
const expectedPreviousExport = {
label: 'previous export label',
attachmentUrl: 'http://attachmentUrl'
@ -540,13 +558,13 @@ test('returns the previous export if state has a previousExport defined', functi
deepEqual(this.wrapper.instance().lastExportFromState(), expectedPreviousExport)
})
test('returns undefined if an export is already in progress', function() {
test('returns undefined if an export is already in progress', function () {
this.wrapper.instance().setExportInProgress(true)
equal(this.wrapper.instance().lastExportFromState(), undefined)
})
test('returns undefined if no previous export is set in the state', function() {
test('returns undefined if no previous export is set in the state', function () {
this.wrapper.instance().setExportInProgress(false)
equal(this.wrapper.instance().lastExportFromState(), undefined)
@ -567,7 +585,7 @@ QUnit.module('ActionMenu - previousExport', {
}
})
test('returns the previous export stored in the state if it is available', function() {
test('returns the previous export stored in the state if it is available', function () {
const stateExport = {
label: 'previous export label',
attachmentUrl: 'http://attachmentUrl'
@ -580,7 +598,7 @@ test('returns the previous export stored in the state if it is available', funct
equal(lastExportFromState.callCount, 1)
})
test('returns the previous export stored in the props if nothing is available in state', function() {
test('returns the previous export stored in the props if nothing is available in state', function () {
const expectedPreviousExport = {
attachmentUrl: 'http://downloadUrl',
label: 'Previous Export (Jan 20, 2009 at 5pm)'
@ -598,7 +616,7 @@ test('returns the previous export stored in the props if nothing is available in
equal(lastExportFromProps.callCount, 1)
})
test('returns undefined if state has nothing and props have nothing', function() {
test('returns undefined if state has nothing and props have nothing', function () {
const lastExportFromState = sandbox
.stub(ActionMenu.prototype, 'lastExportFromState')
.returns(undefined)
@ -621,13 +639,13 @@ QUnit.module('ActionMenu - exportInProgress', {
}
})
test('returns true if exportInProgress is set', function() {
test('returns true if exportInProgress is set', function () {
this.wrapper.instance().setExportInProgress(true)
strictEqual(this.wrapper.instance().exportInProgress(), true)
})
test('returns false if exportInProgress is set to false', function() {
test('returns false if exportInProgress is set to false', function () {
this.wrapper.instance().setExportInProgress(false)
strictEqual(this.wrapper.instance().exportInProgress(), false)
@ -647,7 +665,7 @@ QUnit.module('ActionMenu - Post Grade Ltis', {
}
})
test('Invokes the onSelect prop when selected', function() {
test('Invokes the onSelect prop when selected', function () {
document.querySelector('[data-menu-id="post_grades_lti_1"]').click()
strictEqual(this.props.postGradesLtis[0].onSelect.called, true)
@ -703,7 +721,7 @@ test('Does not render menu item when isEnabled is false and publishToSisUrl is u
equal(menuItem, null)
})
test('Does not render menu item when isEnabled is true and publishToSisUrl is undefined', function() {
test('Does not render menu item when isEnabled is true and publishToSisUrl is undefined', function () {
this.wrapper.setProps({
publishGradesToSis: {
isEnabled: true
@ -716,7 +734,7 @@ test('Does not render menu item when isEnabled is true and publishToSisUrl is un
equal(menuItem, null)
})
test('Renders menu item when isEnabled is true and publishToSisUrl is defined', function() {
test('Renders menu item when isEnabled is true and publishToSisUrl is defined', function () {
this.wrapper.setProps({
publishGradesToSis: {
isEnabled: true,
@ -730,7 +748,7 @@ test('Renders menu item when isEnabled is true and publishToSisUrl is defined',
ok(menuItem)
})
test('Calls gotoUrl with publishToSisUrl when clicked', function() {
test('Calls gotoUrl with publishToSisUrl when clicked', function () {
this.wrapper.setProps({
publishGradesToSis: {
isEnabled: true,

View File

@ -18,7 +18,7 @@
import moxios from 'moxios'
import GradebookExportManager from 'ui/features/gradebook/react/shared/GradebookExportManager.js'
import GradebookExportManager from 'ui/features/gradebook/react/shared/GradebookExportManager'
const currentUserId = 42
const exportingUrl = 'http://exportingUrl'
@ -165,7 +165,8 @@ test('sets show_student_first_last_name setting if requested', function () {
}
const getAssignmentOrder = () => []
return this.subject.startExport(undefined, getAssignmentOrder, true).then(() => {
const getStudentOrder = () => []
return this.subject.startExport(undefined, getAssignmentOrder, true, getStudentOrder).then(() => {
const postData = JSON.parse(moxios.requests.mostRecent().config.data)
propEqual(postData.show_student_first_last_name, true)
})
@ -178,10 +179,13 @@ test('does not set show_student_first_last_name setting by default', function ()
}
const getAssignmentOrder = () => []
return this.subject.startExport(undefined, getAssignmentOrder).then(() => {
const postData = JSON.parse(moxios.requests.mostRecent().config.data)
propEqual(postData.show_student_first_last_name, false)
})
const getStudentOrder = () => []
return this.subject
.startExport(undefined, getAssignmentOrder, false, getStudentOrder)
.then(() => {
const postData = JSON.parse(moxios.requests.mostRecent().config.data)
propEqual(postData.show_student_first_last_name, false)
})
})
test('includes assignment_order if getAssignmentOrder returns some assignments', function () {
@ -191,10 +195,13 @@ test('includes assignment_order if getAssignmentOrder returns some assignments',
}
const getAssignmentOrder = () => ['1', '2', '3']
return this.subject.startExport(undefined, getAssignmentOrder).then(() => {
const postData = JSON.parse(moxios.requests.mostRecent().config.data)
propEqual(postData.assignment_order, ['1', '2', '3'])
})
const getStudentOrder = () => []
return this.subject
.startExport(undefined, getAssignmentOrder, false, getStudentOrder)
.then(() => {
const postData = JSON.parse(moxios.requests.mostRecent().config.data)
propEqual(postData.assignment_order, ['1', '2', '3'])
})
})
test('does not include assignment_order if getAssignmentOrder returns no assignments', function () {
@ -204,10 +211,13 @@ test('does not include assignment_order if getAssignmentOrder returns no assignm
}
const getAssignmentOrder = () => []
return this.subject.startExport(undefined, getAssignmentOrder).then(() => {
const postData = JSON.parse(moxios.requests.mostRecent().config.data)
equal(postData.assignment_order, undefined)
})
const getStudentOrder = () => []
return this.subject
.startExport(undefined, getAssignmentOrder, false, getStudentOrder)
.then(() => {
const postData = JSON.parse(moxios.requests.mostRecent().config.data)
equal(postData.assignment_order, undefined)
})
})
test('returns a rejected promise if the manager has no exportingUrl set', function () {
@ -215,7 +225,12 @@ test('returns a rejected promise if the manager has no exportingUrl set', functi
this.subject.exportingUrl = undefined
return this.subject
.startExport(undefined, () => [])
.startExport(
undefined,
() => [],
false,
() => []
)
.catch(reason => {
equal(reason, 'No way to export gradebooks provided!')
})
@ -225,7 +240,12 @@ test('returns a rejected promise if the manager already has an export going', fu
this.subject = new GradebookExportManager(exportingUrl, currentUserId, workingExport)
return this.subject
.startExport(undefined, () => [])
.startExport(
undefined,
() => [],
false,
() => []
)
.catch(reason => {
equal(reason, 'An export is already in progress.')
})
@ -243,7 +263,12 @@ test('sets a new existing export and returns a fulfilled promise', function () {
}
return this.subject
.startExport(undefined, () => [])
.startExport(
undefined,
() => [],
false,
() => []
)
.then(() => {
deepEqual(this.subject.export, expectedExport)
})
@ -254,7 +279,12 @@ test('clears any new export and returns a rejected promise if no monitoring is p
this.subject = new GradebookExportManager(exportingUrl, currentUserId)
return this.subject
.startExport(undefined, () => [])
.startExport(
undefined,
() => [],
false,
() => []
)
.catch(reason => {
equal(reason, 'No way to monitor gradebook exports provided!')
equal(this.subject.export, undefined)
@ -275,7 +305,12 @@ test('starts polling for progress and returns a rejected promise on progress fai
})
return this.subject
.startExport(undefined, () => [])
.startExport(
undefined,
() => [],
false,
() => []
)
.catch(reason => {
equal(reason, 'Error exporting gradebook: Arbitrary failure')
})
@ -295,7 +330,12 @@ test('starts polling for progress and returns a rejected promise on unknown prog
})
return this.subject
.startExport(undefined, () => [])
.startExport(
undefined,
() => [],
false,
() => []
)
.catch(reason => {
equal(reason, 'Error exporting gradebook: Pattern buffer degradation')
})
@ -323,7 +363,12 @@ test('starts polling for progress and returns a fulfilled promise on progress co
})
return this.subject
.startExport(undefined, () => [])
.startExport(
undefined,
() => [],
false,
() => {}
)
.then(resolution => {
equal(this.subject.export, undefined)

View File

@ -558,55 +558,62 @@ describe GradebookExporter do
describe "with grading periods" do
describe "assignments in the selected grading period are exported" do
before do
@csv = exporter(grading_period_id: @last_period.id).to_csv
@rows = CSV.parse(@csv, headers: true)
@headers = @rows.headers
describe "export entire gradebook" do
before do
@csv = exporter(grading_period_id: @last_period.id).to_csv
@rows = CSV.parse(@csv, headers: true)
@headers = @rows.headers
end
it "exports assignments from all grading periods" do
expect(@headers).to include @no_due_date_assignment.title_with_id,
@current_assignment.title_with_id,
@past_assignment.title_with_id,
@future_assignment.title_with_id
end
it "does not export totals columns" do
expect(@headers).to_not include "Final Score (#{@last_period.title})"
end
end
it "exports selected grading period's assignments" do
expect(@headers).to include @no_due_date_assignment.title_with_id,
@current_assignment.title_with_id
final_grade = @rows[1]["Final Score (#{@last_period.title})"].try(:to_f)
expect(final_grade).to eq 20
end
describe "export current gradebook view" do
before do
exporter_options = {
grading_period_id: @last_period.id,
current_view: true,
assignment_order: @course.assignments.pluck(:id),
student_order: @course.student_enrollments.pluck(:user_id)
}
@csv = exporter(exporter_options).to_csv
@rows = CSV.parse(@csv, headers: true)
@headers = @rows.headers
end
it "exports assignments without due dates if exporting last grading period" do
expect(@headers).to include @current_assignment.title_with_id,
@no_due_date_assignment.title_with_id
final_grade = @rows[1]["Final Score (#{@last_period.title})"].try(:to_f)
expect(final_grade).to eq 20
end
it "exports filtered grading period's assignments with totals columns" do
expect(@headers).to include @no_due_date_assignment.title_with_id,
@current_assignment.title_with_id
final_grade = @rows[1]["Final Score (#{@last_period.title})"].try(:to_f)
expect(final_grade).to eq 20
end
it "does not export assignments without due date" do
@grading_period_id = @first_period.id
@csv = exporter(grading_period_id: @grading_period_id).to_csv
@rows = CSV.parse(@csv, headers: true)
@headers = @rows.headers
it "exports all visible assignments in the gradebook" do
exporter_options = {
grading_period_id: @first_period.id,
current_view: true,
assignment_order: [@no_due_date_assignment.id, @future_assignment.id],
student_order: @course.student_enrollments.pluck(:user_id).map(&:to_s)
}
@csv = exporter(exporter_options).to_csv
@rows = CSV.parse(@csv, headers: true)
@headers = @rows.headers
expect(@headers).to_not include @no_due_date_assignment.title_with_id
end
expect(@headers).to include @no_due_date_assignment.title_with_id,
@future_assignment.title_with_id
it "does not export assignments in other grading periods" do
expect(@headers).to_not include @past_assignment.title_with_id,
@future_assignment.title_with_id
end
it "does not export future assignments" do
expect(@headers).to_not include @future_assignment.title_with_id
end
it "exports the entire gradebook when grading_period_id is 0" do
@grading_period_id = 0
@csv = exporter(grading_period_id: @grading_period_id).to_csv
@rows = CSV.parse(@csv, headers: true)
@headers = @rows.headers
expect(@headers).to include @past_assignment.title_with_id,
@current_assignment.title_with_id,
@future_assignment.title_with_id,
@no_due_date_assignment.title_with_id
expect(@headers).not_to include "Final Score"
expect(@headers).to_not include @current_assignment.title_with_id,
@past_assignment.title_with_id
end
end
end
end
@ -694,7 +701,13 @@ describe GradebookExporter do
end
let(:exporter) do
GradebookExporter.new(@course, @teacher, { grading_period_id: @last_grading_period.id })
exporter_options = {
grading_period_id: @last_grading_period.id,
current_view: true,
assignment_order: @course.assignments.pluck(:id),
student_order: @course.student_enrollments.pluck(:user_id).map(&:to_s)
}
GradebookExporter.new(@course, @teacher, exporter_options)
end
let(:exported_headers) { CSV.parse(exporter.to_csv, headers: true).headers }
@ -863,7 +876,15 @@ describe GradebookExporter do
start_date: 1.week.ago, end_date: 1.week.from_now, title: "test period"
)
end
let(:exporter) { GradebookExporter.new(@course, @teacher, { grading_period_id: grading_period.id }) }
let(:exporter) do
exporter_options = {
grading_period_id: grading_period.id,
current_view: true,
assignment_order: @course.assignments.pluck(:id),
student_order: @course.student_enrollments.pluck(:user_id)
}
GradebookExporter.new(@course, @teacher, exporter_options)
end
before do
allow(exporter).to receive(:enrollments_for_csv).and_return([enrollment])

View File

@ -2021,6 +2021,7 @@ class Gradebook extends React.Component<GradebookProps, GradebookState> {
publishToSisUrl: this.options.publish_to_sis_url
},
gradingPeriodId: this.state.gradingPeriodId,
getStudentOrder: this.getStudentOrder,
getAssignmentOrder: this.getAssignmentOrder
}
const progressData = this.options.gradebook_csv_progress

View File

@ -38,6 +38,7 @@ export type ActionMenuProps = {
gradebookIsEditable: boolean
contextAllowsGradebookUploads: boolean
getAssignmentOrder: any
getStudentOrder: any
gradebookImportUrl: string
showStudentFirstLastName: boolean
lastExport?: {
@ -128,7 +129,7 @@ class ActionMenu extends React.Component<ActionMenuProps, ActionMenuState> {
this.setState({exportInProgress: !!status})
}
handleExport() {
handleExport(currentView) {
this.setExportInProgress(true)
$.flashMessage(I18n.t('Gradebook export started'))
@ -136,7 +137,9 @@ class ActionMenu extends React.Component<ActionMenuProps, ActionMenuState> {
?.startExport(
this.props.gradingPeriodId,
this.props.getAssignmentOrder,
this.props.showStudentFirstLastName
this.props.showStudentFirstLastName,
this.props.getStudentOrder,
currentView
)
.then(resolution => {
this.setExportInProgress(false)
@ -324,11 +327,26 @@ class ActionMenu extends React.Component<ActionMenuProps, ActionMenuState> {
<MenuItem
disabled={this.exportInProgress()}
onSelect={() => {
this.handleExport()
this.handleExport(true)
}}
>
<span data-menu-id="export">
{this.exportInProgress() ? I18n.t('Export in progress') : I18n.t('Export')}
{this.exportInProgress()
? I18n.t('Export in progress')
: I18n.t('Export Current Gradebook View')}
</span>
</MenuItem>
<MenuItem
disabled={this.exportInProgress()}
onSelect={() => {
this.handleExport(false)
}}
>
<span data-menu-id="export-all">
{this.exportInProgress()
? I18n.t('Export in progress')
: I18n.t('Export Entire Gradebook')}
</span>
</MenuItem>

View File

@ -71,12 +71,18 @@ export default function EnhancedActionMenu(props) {
}
}
const handleExport = () => {
const handleExport = currentView => {
setExportInProgress(true)
$.flashMessage(I18n.t('Gradebook export started'))
return exportManager.current
.startExport(props.gradingPeriodId, props.getAssignmentOrder, props.showStudentFirstLastName)
.startExport(
props.gradingPeriodId,
props.getAssignmentOrder,
props.showStudentFirstLastName,
props.getStudentOrder,
currentView
)
.then(resolution => {
setExportInProgress(false)
@ -84,7 +90,7 @@ export default function EnhancedActionMenu(props) {
const updatedAt = new Date(resolution.updatedAt)
const previousExportValue = {
label: `${I18n.t('New Export')} (${DateHelper.formatDatetimeForDisplay(updatedAt)})`,
label: `${I18n.t('Previous Export')} (${DateHelper.formatDatetimeForDisplay(updatedAt)})`,
attachmentUrl
}
@ -288,11 +294,24 @@ export default function EnhancedActionMenu(props) {
<Menu.Item
disabled={exportInProgress}
onSelect={() => {
handleExport()
handleExport(true)
}}
>
<span data-menu-id="export">
{exportInProgress ? I18n.t('Export in progress') : I18n.t('New Export')}
{exportInProgress
? I18n.t('Export in progress')
: I18n.t('Export Current Gradebook View')}
</span>
</Menu.Item>
<Menu.Item
disabled={exportInProgress}
onSelect={() => {
handleExport(false)
}}
>
<span data-menu-id="export-all">
{exportInProgress ? I18n.t('Export in progress') : I18n.t('Export Entire Gradebook')}
</span>
</Menu.Item>
@ -306,6 +325,7 @@ EnhancedActionMenu.propTypes = {
gradebookIsEditable: bool.isRequired,
contextAllowsGradebookUploads: bool.isRequired,
getAssignmentOrder: func.isRequired,
getStudentOrder: func.isRequired,
gradebookImportUrl: string.isRequired,
currentUserId: string.isRequired,

View File

@ -36,6 +36,7 @@ const getPromise = (type, object = defaultResult) => {
const workingMenuProps = () => ({
getAssignmentOrder() {},
getStudentOrder() {},
gradebookIsEditable: true,
contextAllowsGradebookUploads: true,
gradebookImportUrl: 'http://gradebookImportUrl',
@ -130,11 +131,20 @@ describe('EnhancedActionMenu', () => {
expect(specificMenuItem).toHaveTextContent('Import')
})
it('renders the New Export menu item', async () => {
it('renders the Export Current Gradebook View menu item', async () => {
component = renderComponent(props)
clickOnDropdown('Export')
const specificMenuItem = component.getByRole('menuitem', {
name: 'New Export'
name: 'Export Current Gradebook View'
})
expect(specificMenuItem).toBeInTheDocument()
})
it('renders the Export Entire Gradebook menu item', async () => {
component = renderComponent(props)
clickOnDropdown('Export')
const specificMenuItem = component.getByRole('menuitem', {
name: 'Export Entire Gradebook'
})
expect(specificMenuItem).toBeInTheDocument()
})
@ -148,7 +158,7 @@ describe('EnhancedActionMenu', () => {
expect(specificMenuItem).toBeInTheDocument()
})
it('updates the New Export date when export success', async () => {
it('updates the Previous Export date when export success', async () => {
const exportResult = getPromise('resolved', {
...defaultResult,
updatedAt: '2021-05-12T13:00:00Z'
@ -157,12 +167,12 @@ describe('EnhancedActionMenu', () => {
startExport.mockReturnValue(exportResult)
component = renderComponent(props)
clickOnDropdown('Export')
selectDropdownOption('New Export')
selectDropdownOption('Export Entire Gradebook')
await waitFor(() => {
clickOnDropdown('Export')
})
const specificMenuItem = component.getByRole('menuitem', {
name: 'New Export (May 12, 2021 at 1pm)'
name: 'Previous Export (May 12, 2021 at 1pm)'
})
expect(specificMenuItem).toBeInTheDocument()
})
@ -219,7 +229,7 @@ describe('EnhancedActionMenu', () => {
startExport.mockReturnValue(exportResult)
const spy = jest.spyOn(window.$, 'flashMessage').mockReturnValue(true)
act(() => {
selectDropdownOption('New Export')
selectDropdownOption('Export Current Gradebook View')
})
await waitFor(() => {
expect(spy).toHaveBeenCalled()
@ -227,17 +237,19 @@ describe('EnhancedActionMenu', () => {
})
})
it('changes the "Export" menu item to indicate the export is in progress', async () => {
it('changes the "Export Current Gradebook View" and "Export Entire Gradebook" menu items to indicate the export is in progress', async () => {
const exportResult = getPromise('resolved')
startExport.mockReturnValue(exportResult)
act(() => {
selectDropdownOption('New Export')
selectDropdownOption('Export Current Gradebook View')
})
clickOnDropdown('Export')
const specificMenuItem = component.getByRole('menuitem', {name: /Export in progress/})
const specificMenuItems = component.getAllByRole('menuitem', {name: /Export in progress/})
await waitFor(() => {
expect(specificMenuItem).toBeInTheDocument()
expect(specificMenuItem).toHaveAttribute('aria-disabled', 'true')
expect(specificMenuItems[0]).toBeInTheDocument()
expect(specificMenuItems[1]).toBeInTheDocument()
expect(specificMenuItems[0]).toHaveAttribute('aria-disabled', 'true')
expect(specificMenuItems[1]).toHaveAttribute('aria-disabled', 'true')
})
})
@ -245,7 +257,7 @@ describe('EnhancedActionMenu', () => {
const exportResult = getPromise('resolved')
startExport.mockReturnValue(exportResult)
act(() => {
selectDropdownOption('New Export')
selectDropdownOption('Export Current Gradebook View')
})
await waitFor(() => {
expect(startExport).toHaveBeenCalled()
@ -256,7 +268,7 @@ describe('EnhancedActionMenu', () => {
const exportResult = getPromise('resolved')
startExport.mockReturnValue(exportResult)
act(() => {
selectDropdownOption('New Export')
selectDropdownOption('Export Current Gradebook View')
})
await waitFor(() => {
expect(startExport.mock.calls[0][0]).toEqual('1234')
@ -267,7 +279,7 @@ describe('EnhancedActionMenu', () => {
const exportResult = getPromise('resolved')
startExport.mockReturnValue(exportResult)
act(() => {
selectDropdownOption('New Export')
selectDropdownOption('Export Current Gradebook View')
})
await waitFor(() => {
expect(startExport.mock.calls[0][2]).toEqual(true)
@ -278,25 +290,43 @@ describe('EnhancedActionMenu', () => {
const exportResult = getPromise('resolved')
startExport.mockReturnValue(exportResult)
act(() => {
selectDropdownOption('New Export')
selectDropdownOption('Export Current Gradebook View')
})
await waitFor(() => expect(window.location.href).toEqual(defaultResult.attachmentUrl))
})
it('on success, re-enables the "New Export" menu item', async () => {
it('on success, re-enables the "Export Entire Gradebook" menu item', async () => {
const exportResult = getPromise('resolved')
startExport.mockReturnValue(exportResult)
act(() => {
selectDropdownOption('New Export')
selectDropdownOption('Export Entire Gradebook')
})
await waitFor(() => {
clickOnDropdown('Export')
})
const specificMenuItem = document
.querySelector('[data-menu-id="previous-export"]')
.querySelector('[data-menu-id="export-all"]')
.closest('[role="menuitem"]')
await waitFor(() => {
expect(specificMenuItem).toHaveTextContent('New Export')
expect(specificMenuItem).toHaveTextContent('Export Entire Gradebook')
expect(specificMenuItem).not.toHaveAttribute('aria-disabled', 'true')
})
})
it('on success, re-enables the "Export Current Gradebook View" menu item', async () => {
const exportResult = getPromise('resolved')
startExport.mockReturnValue(exportResult)
act(() => {
selectDropdownOption('Export Current Gradebook View')
})
await waitFor(() => {
clickOnDropdown('Export')
})
const specificMenuItem = document
.querySelector('[data-menu-id="export"]')
.closest('[role="menuitem"]')
await waitFor(() => {
expect(specificMenuItem).toHaveTextContent('Export Current Gradebook View')
expect(specificMenuItem).not.toHaveAttribute('aria-disabled', 'true')
})
})
@ -306,7 +336,7 @@ describe('EnhancedActionMenu', () => {
const exportResult = getPromise('rejected')
startExport.mockReturnValue(exportResult)
act(() => {
selectDropdownOption('New Export')
selectDropdownOption('Export Current Gradebook View')
})
await waitFor(() => {
expect(spy).toHaveBeenCalled()
@ -316,21 +346,26 @@ describe('EnhancedActionMenu', () => {
})
})
it('on failure, renables the "New Export" menu item', async () => {
it('on failure, renables the "Export Current Gradebook View" and "Export Entire Gradebook" menu items', async () => {
const exportResult = getPromise('rejected')
startExport.mockReturnValue(exportResult)
act(() => {
selectDropdownOption('New Export')
selectDropdownOption('Export Current Gradebook View')
})
await waitFor(() => {
clickOnDropdown('Export')
})
const specificMenuItem = document
const exportMenuItem = document
.querySelector('[data-menu-id="export"]')
.closest('[role="menuitem"]')
const exportAllMenuItem = document
.querySelector('[data-menu-id="export-all"]')
.closest('[role="menuitem"]')
await waitFor(() => {
expect(specificMenuItem).toHaveTextContent('New Export')
expect(specificMenuItem).not.toHaveAttribute('aria-disabled', 'true')
expect(exportMenuItem).toHaveTextContent('Export Current Gradebook View')
expect(exportMenuItem).not.toHaveAttribute('aria-disabled', 'true')
expect(exportAllMenuItem).toHaveTextContent('Export Entire Gradebook')
expect(exportAllMenuItem).not.toHaveAttribute('aria-disabled', 'true')
})
})
})

View File

@ -119,7 +119,13 @@ class GradebookExportManager {
}, this.pollingInterval)
}
startExport(gradingPeriodId, getAssignmentOrder, showStudentFirstLastName = false) {
startExport(
gradingPeriodId,
getAssignmentOrder,
showStudentFirstLastName = false,
getStudentOrder,
currentView = false
) {
if (!this.exportingUrl) {
return Promise.reject(I18n.t('No way to export gradebooks provided!'))
}
@ -130,7 +136,9 @@ class GradebookExportManager {
}
const params = {
grading_period_id: gradingPeriodId
grading_period_id: gradingPeriodId,
show_student_first_last_name: showStudentFirstLastName,
current_view: currentView
}
const assignmentOrder = getAssignmentOrder()
@ -138,7 +146,10 @@ class GradebookExportManager {
params.assignment_order = assignmentOrder
}
params.show_student_first_last_name = showStudentFirstLastName
const studentOrder = getStudentOrder()
if (studentOrder && studentOrder.length > 0) {
params.student_order = studentOrder.map(Number)
}
return axios.post(this.exportingUrl, params).then(response => {
this.export = {