diff --git a/app/controllers/gradebook_csvs_controller.rb b/app/controllers/gradebook_csvs_controller.rb index 74c26527a3e..708f1d09e75 100644 --- a/app/controllers/gradebook_csvs_controller.rb +++ b/app/controllers/gradebook_csvs_controller.rb @@ -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] diff --git a/lib/gradebook_exporter.rb b/lib/gradebook_exporter.rb index b52dc149f6f..81926c0e54c 100644 --- a/lib/gradebook_exporter.rb +++ b/lib/gradebook_exporter.rb @@ -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 diff --git a/spec/javascripts/jsx/gradebook/default_gradebook/components/ActionMenuSpec.js b/spec/javascripts/jsx/gradebook/default_gradebook/components/ActionMenuSpec.js index 4b4ce3800e4..07f73a148d6 100644 --- a/spec/javascripts/jsx/gradebook/default_gradebook/components/ActionMenuSpec.js +++ b/spec/javascripts/jsx/gradebook/default_gradebook/components/ActionMenuSpec.js @@ -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() @@ -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() 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, diff --git a/spec/javascripts/jsx/gradebook/shared/GradebookExportManagerSpec.js b/spec/javascripts/jsx/gradebook/shared/GradebookExportManagerSpec.js index 6d5ed4b8dd1..2e8d928a525 100644 --- a/spec/javascripts/jsx/gradebook/shared/GradebookExportManagerSpec.js +++ b/spec/javascripts/jsx/gradebook/shared/GradebookExportManagerSpec.js @@ -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) diff --git a/spec/lib/gradebook_exporter_spec.rb b/spec/lib/gradebook_exporter_spec.rb index c3f8f9101f2..3506539870a 100644 --- a/spec/lib/gradebook_exporter_spec.rb +++ b/spec/lib/gradebook_exporter_spec.rb @@ -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]) diff --git a/ui/features/gradebook/react/default_gradebook/Gradebook.tsx b/ui/features/gradebook/react/default_gradebook/Gradebook.tsx index 66892050a25..d44aca78bff 100644 --- a/ui/features/gradebook/react/default_gradebook/Gradebook.tsx +++ b/ui/features/gradebook/react/default_gradebook/Gradebook.tsx @@ -2021,6 +2021,7 @@ class Gradebook extends React.Component { publishToSisUrl: this.options.publish_to_sis_url }, gradingPeriodId: this.state.gradingPeriodId, + getStudentOrder: this.getStudentOrder, getAssignmentOrder: this.getAssignmentOrder } const progressData = this.options.gradebook_csv_progress diff --git a/ui/features/gradebook/react/default_gradebook/components/ActionMenu.tsx b/ui/features/gradebook/react/default_gradebook/components/ActionMenu.tsx index 7044e212c96..06c00252a9b 100644 --- a/ui/features/gradebook/react/default_gradebook/components/ActionMenu.tsx +++ b/ui/features/gradebook/react/default_gradebook/components/ActionMenu.tsx @@ -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 { 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 { ?.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 { { - this.handleExport() + this.handleExport(true) }} > - {this.exportInProgress() ? I18n.t('Export in progress') : I18n.t('Export')} + {this.exportInProgress() + ? I18n.t('Export in progress') + : I18n.t('Export Current Gradebook View')} + + + + { + this.handleExport(false) + }} + > + + {this.exportInProgress() + ? I18n.t('Export in progress') + : I18n.t('Export Entire Gradebook')} diff --git a/ui/features/gradebook/react/default_gradebook/components/EnhancedActionMenu.js b/ui/features/gradebook/react/default_gradebook/components/EnhancedActionMenu.js index 366e5bf781d..c8754f1fdd9 100644 --- a/ui/features/gradebook/react/default_gradebook/components/EnhancedActionMenu.js +++ b/ui/features/gradebook/react/default_gradebook/components/EnhancedActionMenu.js @@ -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) { { - handleExport() + handleExport(true) }} > - {exportInProgress ? I18n.t('Export in progress') : I18n.t('New Export')} + {exportInProgress + ? I18n.t('Export in progress') + : I18n.t('Export Current Gradebook View')} + + + + { + handleExport(false) + }} + > + + {exportInProgress ? I18n.t('Export in progress') : I18n.t('Export Entire Gradebook')} @@ -306,6 +325,7 @@ EnhancedActionMenu.propTypes = { gradebookIsEditable: bool.isRequired, contextAllowsGradebookUploads: bool.isRequired, getAssignmentOrder: func.isRequired, + getStudentOrder: func.isRequired, gradebookImportUrl: string.isRequired, currentUserId: string.isRequired, diff --git a/ui/features/gradebook/react/default_gradebook/components/__tests__/EnhancedActionMenu.test.js b/ui/features/gradebook/react/default_gradebook/components/__tests__/EnhancedActionMenu.test.js index ad800238919..c09ebb9fb7d 100644 --- a/ui/features/gradebook/react/default_gradebook/components/__tests__/EnhancedActionMenu.test.js +++ b/ui/features/gradebook/react/default_gradebook/components/__tests__/EnhancedActionMenu.test.js @@ -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') }) }) }) diff --git a/ui/features/gradebook/react/shared/GradebookExportManager.js b/ui/features/gradebook/react/shared/GradebookExportManager.js index 0257665d500..f3a0f5dc5b5 100644 --- a/ui/features/gradebook/react/shared/GradebookExportManager.js +++ b/ui/features/gradebook/react/shared/GradebookExportManager.js @@ -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 = {