Add student group filter to new gradebook

Add a filter for student groups to new gradebook. It can be enabled
from the View menu (as with the other filters) and behaves generally as
they do, with the small difference of showing groups/group categories
hierarchically. Also add the option to show a student's groups as their
"secondary info" below their name.

closes GRADE-2122

Test plan:
- Have a course (using New Gradebook) and some students
- Create some student groups for the course, and assign
  students to them
- Open New Gradebook
- The "View" menu should have a "Student Groups" filter in addition to
  the existing filters
  - Enabling it should make a filter appear that lets you choose between
    the student groups for the course (or show all groups)
  - Selecting a particular group should show only students belonging to
    that group
  - Selecting "All Student Groups" should show all students
  - Your selection should persist between page views
- The ellipsis menu in the Student Name column should have a new "Group"
  option under "Secondary Info"
  - Selecting it should set the secondary text below a student's name to
    the groups that student is part of

Change-Id: Id6b1c62699f1778e60545ba85f945ebecfc3e98a
Reviewed-on: https://gerrit.instructure.com/194096
Reviewed-by: Keith Garner <kgarner@instructure.com>
Reviewed-by: Jeremy Neander <jneander@instructure.com>
Tested-by: Jenkins
QA-Review: Derek Bender <djbender@instructure.com>
Product-Review: Matthew Goodwin <mattg@instructure.com>
This commit is contained in:
Adrian Packel 2019-02-11 15:53:58 -06:00
parent 45fb184f9d
commit c858092ceb
29 changed files with 1122 additions and 21 deletions

View File

@ -77,6 +77,7 @@ import OutlierScoreHelper from 'jsx/grading/helpers/OutlierScoreHelper'
import LatePolicyApplicator from 'jsx/grading/LatePolicyApplicator'
import Button from '@instructure/ui-buttons/lib/components/Button'
import IconSettingsSolid from '@instructure/ui-icons/lib/Solid/IconSettings'
import StudentGroupFilter from 'jsx/gradezilla/default_gradebook/components/StudentGroupFilter'
import * as FlashAlert from 'jsx/shared/FlashAlert'
import 'jquery.ajaxJSON'
import 'jquery.instructure_date_and_time'
@ -162,6 +163,7 @@ export default do ->
filterRowsBy =
sectionId: null
studentGroupId: null
if settings.filter_rows_by?
Object.assign(filterRowsBy, ConvertCase.camelize(settings.filter_rows_by))
@ -239,6 +241,13 @@ export default do ->
anonymousSpeedGraderAlertMountPoint = () ->
document.querySelector("[data-component='AnonymousSpeedGraderAlert']")
formatStudentGroupsForFilter = (groupCategoryList) ->
groupCategoryList.map((category) => {
children: category.groups.sort((a, b) => a.id - b.id),
id: category.id,
name: category.name
})
class Gradebook
columnWidths =
assignment:
@ -364,6 +373,8 @@ export default do ->
else
@gridDisplaySettings.selectedSecondaryInfo = 'none'
@setStudentGroups(@options.student_groups)
bindGridEvents: =>
@gradebookGrid.events.onColumnsReordered.subscribe (_event, columns) =>
# determine if assignment columns or custom columns were reordered
@ -1148,6 +1159,42 @@ export default do ->
showSections: ->
@sections_enabled
showStudentGroups: ->
@studentGroupsEnabled
updateStudentGroupFilterVisibility: ->
mountPoint = document.getElementById('student-group-filter-container')
if @showStudentGroups() and 'studentGroups' in @gridDisplaySettings.selectedViewOptionsFilters
groupCategoryList = Object.values(@studentGroupCategories).sort((a, b) => (a.id - b.id))
props =
items: formatStudentGroupsForFilter(groupCategoryList)
onSelect: @updateCurrentStudentGroup
selectedItemId: @getStudentGroupToShow()
disabled: !@contentLoadStates.studentsLoaded
@studentGroupFilterMenu = renderComponent(StudentGroupFilter, mountPoint, props)
else
@updateCurrentStudentGroup(null)
if @studentGroupFilterMenu
ReactDOM.unmountComponentAtNode(mountPoint)
@studentGroupFilterMenu = null
getStudentGroupToShow: () =>
groupId = @getFilterRowsBySetting('studentGroupId') || '0'
if groupId in Object.keys(@studentGroups) then groupId else '0'
updateCurrentStudentGroup: (groupId) =>
groupId = if groupId == '0' then null else groupId
if @getFilterRowsBySetting('studentGroupId') != groupId
@setFilterRowsBySetting('studentGroupId', groupId)
@saveSettings({}, =>
@updateStudentGroupFilterVisibility()
@reloadStudentData()
)
assignmentGroupList: ->
return [] unless @assignmentGroups
Object.values(@assignmentGroups).sort((a, b) => (a.position - b.position))
@ -1415,6 +1462,7 @@ export default do ->
# available, whereas assignment groups and context modules are fetched via the DataLoader,
# so we need to wait until they are loaded to set their filter visibility.
@updateSectionFilterVisibility()
@updateStudentGroupFilterVisibility()
@updateAssignmentGroupFilterVisibility() if @contentLoadStates.assignmentGroupsLoaded
@updateGradingPeriodFilterVisibility()
@updateModulesFilterVisibility() if @contentLoadStates.contextModulesLoaded
@ -2612,6 +2660,7 @@ export default do ->
filters.push('gradingPeriods') if @gradingPeriodSet?
filters.push('modules') if @listContextModules().length > 0
filters.push('sections') if @sections_enabled
filters.push('studentGroups') if @studentGroupsEnabled
filters
setSelectedViewOptionsFilters: (filters) =>
@ -2690,6 +2739,13 @@ export default do ->
@sections = _.indexBy(sections, 'id')
@sections_enabled = sections.length > 1
setStudentGroups: (groupCategories) =>
@studentGroupCategories = _.indexBy(groupCategories.map(htmlEscape), 'id')
studentGroupList = _.flatten(_.pluck(groupCategories, 'groups')).map(htmlEscape)
@studentGroups = _.indexBy(studentGroupList, 'id')
@studentGroupsEnabled = studentGroupList.length > 0
setAssignments: (assignmentMap) =>
@assignments = assignmentMap

View File

@ -44,7 +44,8 @@ class GradebookSettingsController < ApplicationController
:assignment_group_id
],
filter_rows_by: [
:section_id
:section_id,
:student_group_id
],
selected_view_options_filters: []
},

View File

@ -21,6 +21,7 @@ class GradebooksController < ApplicationController
include GradebooksHelper
include KalturaHelper
include Api::V1::AssignmentGroup
include Api::V1::GroupCategory
include Api::V1::Submission
include Api::V1::CustomGradebookColumn
include Api::V1::Section
@ -411,6 +412,7 @@ class GradebooksController < ApplicationController
sections: sections_json(@context.active_course_sections, @current_user, session, [], allow_sis_ids: true),
settings_update_url: api_v1_course_gradebook_settings_update_url(@context),
settings: gradebook_settings.fetch(@context.id, {}),
student_groups: group_categories_json(@context.group_categories, @current_user, session, {include: ['groups']}),
login_handle_name: @context.root_account.settings[:login_handle_name],
sis_name: @context.root_account.settings[:sis_name],
version: params.fetch(:version, nil),
@ -680,6 +682,7 @@ class GradebooksController < ApplicationController
if new_gradebook_enabled?
env[:selected_section_id] = gradebook_settings.dig(@context.id, 'filter_rows_by', 'section_id')
env[:post_policies_enabled] = true if @context.feature_enabled?(:post_policies)
env[:selected_student_group_id] = gradebook_settings.dig(@context.id, 'filter_rows_by', 'student_group_id')
end
if @assignment.quiz
@ -809,6 +812,13 @@ class GradebooksController < ApplicationController
end
helper_method :multiple_assignment_groups?
def student_groups?
return @student_groups if defined?(@student_groups)
@student_groups = @context.groups.any?
end
helper_method :student_groups?
private
def outcome_proficiency

View File

@ -25,6 +25,12 @@ function getSecondaryDisplayInfo(student, secondaryInfo, options) {
const sectionNames = student.sections.map(sectionId => options.getSection(sectionId).name)
return $.toSentence(sectionNames.sort())
}
if (options.shouldShowGroups() && secondaryInfo === 'group') {
const groupNames = student.group_ids.map(groupId => options.getGroup(groupId).name)
return $.toSentence(groupNames.sort())
}
return {
login_id: student.login_id,
sis_id: student.sis_user_id,
@ -82,6 +88,9 @@ export default class StudentCellFormatter {
getSection(sectionId) {
return gradebook.sections[sectionId]
},
getGroup(groupId) {
return gradebook.studentGroups[groupId]
},
getSelectedPrimaryInfo() {
return gradebook.getSelectedPrimaryInfo()
},
@ -90,6 +99,9 @@ export default class StudentCellFormatter {
},
shouldShowSections() {
return gradebook.showSections()
},
shouldShowGroups() {
return gradebook.showStudentGroups()
}
}
}

View File

@ -41,6 +41,7 @@ export default class StudentColumnHeader extends ColumnHeader {
sisName: string,
selectedSecondaryInfo: oneOf(studentRowHeaderConstants.secondaryInfoKeys).isRequired,
sectionsEnabled: bool.isRequired,
studentGroupsEnabled: bool.isRequired,
onSelectSecondaryInfo: func.isRequired,
sortBySetting: shape({
direction: string.isRequired,
@ -84,6 +85,10 @@ export default class StudentColumnHeader extends ColumnHeader {
this.onSelectSecondaryInfo('login_id')
}
onShowGroup = () => {
this.onSelectSecondaryInfo('group')
}
onShowFirstLastNames = () => {
this.onSelectPrimaryInfo('first_last')
}
@ -271,6 +276,16 @@ export default class StudentColumnHeader extends ColumnHeader {
studentRowHeaderConstants.secondaryInfoLabels.login_id}
</MenuItem>
{this.props.studentGroupsEnabled && (
<MenuItem
key="group"
selected={this.props.selectedSecondaryInfo === 'group'}
onSelect={this.onShowGroup}
>
{studentRowHeaderConstants.secondaryInfoLabels.group}
</MenuItem>
)}
<MenuItem
key="none"
selected={this.props.selectedSecondaryInfo === 'none'}

View File

@ -55,7 +55,8 @@ function getProps(gradebook, options) {
gradebook.setSortRowsBySetting(columnId, 'sortable_name', 'descending')
},
settingKey: sortRowsBySetting.settingKey
}
},
studentGroupsEnabled: gradebook.showStudentGroups()
}
}

View File

@ -63,6 +63,7 @@ export function createGradebook(options = {}) {
},
settings_update_url: '/path/to/settingsUpdateUrl',
speed_grader_enabled: true,
student_groups: [],
...options
})
@ -93,6 +94,7 @@ export function setFixtureHtml($fixture) {
<div id="grading-periods-filter-container"></div>
<div id="modules-filter-container"></div>
<div id="sections-filter-container"></div>
<div id="student-group-filter-container"></div>
<div id="search-filter-container">
<input type="text" />
</div>

View File

@ -22,10 +22,33 @@ import Select from '@instructure/ui-core/lib/components/Select'
import ScreenReaderContent from '@instructure/ui-a11y/lib/components/ScreenReaderContent'
import I18n from 'i18n!gradezilla'
function renderItem(item) {
return (
<option key={item.id} value={item.id}>
{item.name}
</option>
)
}
function renderItemAndChildren(item) {
return (
<optgroup label={item.name} key={`group_${item.id}`}>
{item.children.map(child => renderItem(child))}
</optgroup>
)
}
class GradebookFilter extends React.Component {
static propTypes = {
items: arrayOf(
shape({
/* groups can only ever be a single level deep */
children: arrayOf(
shape({
id: string.isRequired,
name: string.isRequired
})
),
id: string.isRequired,
name: string.isRequired
})
@ -59,11 +82,10 @@ class GradebookFilter extends React.Component {
<option key="0" value="0">
{this.props.allItemsLabel}
</option>
{this.props.items.map(item => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
{this.props.items.map(item =>
item.children ? renderItemAndChildren(item) : renderItem(item)
)}
</Select>
)
}

View File

@ -0,0 +1,28 @@
/*
* Copyright (C) 2019 - 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 I18n from 'i18n!gradebook'
import GradebookFilter from './GradebookFilter'
export default class StudentGroupFilter extends GradebookFilter {
static defaultProps = {
allItemsLabel: I18n.t('All Student Groups'),
disabled: false,
filterLabel: I18n.t('Student Group Filter')
}
}

View File

@ -22,7 +22,8 @@ export const filterLabels = {
assignmentGroups: I18n.t('Assignment Groups'),
gradingPeriods: I18n.t('Grading Periods'),
modules: I18n.t('Modules'),
sections: I18n.t('Sections')
sections: I18n.t('Sections'),
studentGroups: I18n.t('Student Groups')
}
export default {

View File

@ -27,6 +27,7 @@ const primaryInfoKeys = ['first_last', 'last_first']
const defaultPrimaryInfo = 'first_last'
const secondaryInfoLabels = {
group: I18n.t('Group'),
section: I18n.t('Section'),
sis_id: I18n.t('SIS ID'),
integration_id: I18n.t('Integration ID'),
@ -34,7 +35,7 @@ const secondaryInfoLabels = {
none: I18n.t('None')
}
const secondaryInfoKeys = ['section', 'sis_id', 'integration_id', 'login_id', 'none']
const secondaryInfoKeys = ['section', 'sis_id', 'integration_id', 'login_id', 'group', 'none']
const defaultSecondaryInfo = 'none'
const sectionSecondaryInfo = 'section'

View File

@ -65,6 +65,10 @@
<div class="gradebook-filter-container" id="sections-filter-container"></div>
<% end %>
<% if student_groups? %>
<div class="gradebook-filter-container" id="student-group-filter-container"></div>
<% end %>
<div class="gradebook-filter-container gradebook_filter" id="search-filter-container">
<% placeholder = t('Search…') %>
<input

View File

@ -20,6 +20,7 @@ module Api::V1::GroupCategory
include Api::V1::Json
include Api::V1::Context
include Api::V1::Progress
include Api::V1::Group
API_GROUP_CATEGORY_JSON_OPTS = {
:only => %w(id name role self_signup group_limit auto_leader)
@ -66,7 +67,14 @@ module Api::V1::GroupCategory
if includes.include?('unassigned_users_count')
hash['unassigned_users_count'] = group_category.unassigned_users.count(:all)
end
if includes.include?('groups')
hash['groups'] = group_category.groups.map { |group| group_json(group, user, session) }
end
end
hash
end
def group_categories_json(group_categories, user, session, options = {})
group_categories.map { |group_category| group_category_json(group_category, user, session, options) }
end
end

View File

@ -26,6 +26,7 @@ class GradebookUserIds
@sort_by = settings[:sort_rows_by_setting_key] || "name"
@selected_grading_period_id = settings.dig(:filter_columns_by, :grading_period_id)
@selected_section_id = settings.dig(:filter_rows_by, :section_id)
@selected_student_group_id = settings.dig(:filter_rows_by, :student_group_id)
@direction = settings[:sort_rows_by_direction] || "ascending"
end
@ -141,7 +142,14 @@ class GradebookUserIds
end
def students
User.left_joins(:enrollments).merge(student_enrollments_scope)
students = User.left_joins(:enrollments).merge(student_enrollments_scope)
if student_group_id.present?
students.joins(group_memberships: :group).
where(group_memberships: {group: student_group_id, workflow_state: :accepted})
else
students
end
end
def sort_by_scores(type = :total_grade, id = nil)
@ -194,7 +202,12 @@ class GradebookUserIds
end
def section_id
return nil if @selected_section_id.nil? || @selected_section_id == "null" || @section_section_id == "0"
return nil if @selected_section_id.nil? || @selected_section_id == "null" || @selected_section_id == "0"
@selected_section_id
end
def student_group_id
return nil if @selected_student_group_id.nil? || ["0", "null"].include?(@selected_student_group_id)
@selected_student_group_id
end
end

View File

@ -38,7 +38,8 @@ RSpec.describe GradebookSettingsController, type: :controller do
"assignment_group_id" => "888"
},
"filter_rows_by" => {
"section_id" => "null"
"section_id" => "null",
"student_group_id" => "null"
},
"selected_view_options_filters" => ["assignmentGroups"],
"show_inactive_enrollments" => "true", # values must be strings
@ -60,7 +61,7 @@ RSpec.describe GradebookSettingsController, type: :controller do
end
let(:gradebook_settings_massaged) do
gradebook_settings.merge('filter_rows_by' => { 'section_id' => nil })
gradebook_settings.merge('filter_rows_by' => { 'section_id' => nil, 'student_group_id' => nil })
end
let(:valid_params) do
@ -85,7 +86,7 @@ RSpec.describe GradebookSettingsController, type: :controller do
it { expect(response).to be_ok }
it { is_expected.to include 'enter_grades_as' => {'2301' => 'points'} }
it { is_expected.to include 'filter_columns_by' => {'grading_period_id' => '1401', 'assignment_group_id' => '888'} }
it { is_expected.to include 'filter_rows_by' => {'section_id' => nil} }
it { is_expected.to include 'filter_rows_by' => {'section_id' => nil, 'student_group_id' => nil} }
it { is_expected.to include 'selected_view_options_filters' => ['assignmentGroups'] }
it { is_expected.to include 'show_inactive_enrollments' => 'true' }
it { is_expected.to include 'show_concluded_enrollments' => 'false' }

View File

@ -981,6 +981,30 @@ describe GradebooksController do
end
end
describe "student_groups" do
let(:category) { @course.group_categories.create!(name: "category") }
let(:category2) { @course.group_categories.create!(name: "another category") }
let(:group_categories_json) { assigns[:js_env][:GRADEBOOK_OPTIONS][:student_groups] }
before(:each) do
category.create_groups(2)
category2.create_groups(2)
end
it "includes the student group categories for the course" do
get :show, params: {course_id: @course.id}
expect(group_categories_json.pluck("id")).to contain_exactly(category.id, category2.id)
end
it "includes the groups within each category" do
get :show, params: {course_id: @course.id}
category2_json = group_categories_json.find { |category_json| category_json["id"] == category2.id }
expect(category2_json["groups"].pluck("id")).to match_array(category2.groups.pluck(:id))
end
end
context "publish_to_sis_enabled" do
before(:once) do
@course.sis_source_id = 'xyz'

View File

@ -2189,6 +2189,7 @@ QUnit.module('Gradebook#getFilterSettingsViewOptionsMenuProps', {
this.gradebook.gradingPeriodSet = {id: '1501'}
this.gradebook.setContextModules([{id: '2601'}, {id: '2602'}])
this.gradebook.sections_enabled = true
this.gradebook.studentGroupsEnabled = true
sandbox.stub(this.gradebook, 'renderViewOptionsMenu')
sandbox.stub(this.gradebook, 'renderFilters')
sandbox.stub(this.gradebook, 'saveSettings')
@ -2197,37 +2198,49 @@ QUnit.module('Gradebook#getFilterSettingsViewOptionsMenuProps', {
test('includes available filters', function() {
const props = this.gradebook.getFilterSettingsViewOptionsMenuProps()
deepEqual(props.available, ['assignmentGroups', 'gradingPeriods', 'modules', 'sections'])
deepEqual(props.available, [
'assignmentGroups',
'gradingPeriods',
'modules',
'sections',
'studentGroups'
])
})
test('available filters exclude assignment groups when only one exists', function() {
this.gradebook.setAssignmentGroups({301: {name: 'Assignments'}})
const props = this.gradebook.getFilterSettingsViewOptionsMenuProps()
deepEqual(props.available, ['gradingPeriods', 'modules', 'sections'])
deepEqual(props.available, ['gradingPeriods', 'modules', 'sections', 'studentGroups'])
})
test('available filters exclude assignment groups when not loaded', function() {
this.gradebook.setAssignmentGroups(undefined)
const props = this.gradebook.getFilterSettingsViewOptionsMenuProps()
deepEqual(props.available, ['gradingPeriods', 'modules', 'sections'])
deepEqual(props.available, ['gradingPeriods', 'modules', 'sections', 'studentGroups'])
})
test('available filters exclude grading periods when no grading period set exists', function() {
this.gradebook.gradingPeriodSet = null
const props = this.gradebook.getFilterSettingsViewOptionsMenuProps()
deepEqual(props.available, ['assignmentGroups', 'modules', 'sections'])
deepEqual(props.available, ['assignmentGroups', 'modules', 'sections', 'studentGroups'])
})
test('available filters exclude modules when none exist', function() {
this.gradebook.setContextModules([])
const props = this.gradebook.getFilterSettingsViewOptionsMenuProps()
deepEqual(props.available, ['assignmentGroups', 'gradingPeriods', 'sections'])
deepEqual(props.available, ['assignmentGroups', 'gradingPeriods', 'sections', 'studentGroups'])
})
test('available filters exclude sections when only one exists', function() {
this.gradebook.sections_enabled = false
const props = this.gradebook.getFilterSettingsViewOptionsMenuProps()
deepEqual(props.available, ['assignmentGroups', 'gradingPeriods', 'modules'])
deepEqual(props.available, ['assignmentGroups', 'gradingPeriods', 'modules', 'studentGroups'])
})
test('available filters exclude student groups when none exist', function() {
this.gradebook.studentGroupsEnabled = false
const props = this.gradebook.getFilterSettingsViewOptionsMenuProps()
deepEqual(props.available, ['assignmentGroups', 'gradingPeriods', 'modules', 'sections'])
})
test('includes selected filters', function() {
@ -2944,6 +2957,134 @@ test('does not render when filter is not selected', function() {
strictEqual(this.container.children.length, 0, 'rendered elements have been removed')
})
QUnit.module('Gradebook#updateStudentGroupFilterVisibility', hooks => {
let gradebook
let container
hooks.beforeEach(() => {
const studentGroupFilterContainerSelector = 'student-group-filter-container'
$fixtures.innerHTML = `<div id="${studentGroupFilterContainerSelector}"></div>`
container = $fixtures.querySelector(`#${studentGroupFilterContainerSelector}`)
const studentGroups = [
{
groups: [{id: '1', name: 'First Group Set 1'}, {id: '2', name: 'First Group Set 2'}],
id: '1',
name: 'First Group Set'
},
{
groups: [{id: '3', name: 'Second Group Set 1'}, {id: '4', name: 'Second Group Set 2'}],
id: '2',
name: 'Second Group Set'
}
]
gradebook = createGradebook({student_groups: studentGroups})
gradebook.studentGroupsEnabled = true
gradebook.setSelectedViewOptionsFilters(['studentGroups'])
})
hooks.afterEach(() => {
$fixtures.innerHTML = ''
})
test('renders the section select when not already rendered', () => {
gradebook.updateStudentGroupFilterVisibility()
ok(container.children.length > 0, 'student group menu was rendered')
})
test('stores a reference to the section select when it is rendered', () => {
gradebook.updateStudentGroupFilterVisibility()
ok(gradebook.studentGroupFilterMenu, 'student group menu reference has been stored')
})
test('does not render when there are no student groups', () => {
gradebook.studentGroupsEnabled = false
gradebook.updateStudentGroupFilterVisibility()
notOk(gradebook.studentGroupFilterMenu, 'student group menu reference has not been stored')
strictEqual(container.children.length, 0, 'nothing was rendered')
})
test('does not render when filter is not selected', () => {
gradebook.setSelectedViewOptionsFilters(['assignmentGroups'])
gradebook.updateStudentGroupFilterVisibility()
notOk(gradebook.studentGroupFilterMenu, 'student group menu reference has been removed')
strictEqual(container.children.length, 0, 'rendered elements have been removed')
})
test('renders the group select with group categories at the top level', () => {
gradebook.updateStudentGroupFilterVisibility()
const studentGroupCategories = gradebook.studentGroupFilterMenu.props.items
deepEqual(studentGroupCategories.map(group => group.id), ['1', '2'])
})
test('renders the group select with all groups', () => {
gradebook.updateStudentGroupFilterVisibility()
const studentGroupCategories = gradebook.studentGroupFilterMenu.props.items
const studentGroups = _.flatten(studentGroupCategories.map(category => category.children))
deepEqual(studentGroups.map(group => group.id), ['1', '2', '3', '4'])
})
test('unescapes student group category names', () => {
gradebook.updateStudentGroupFilterVisibility()
const studentGroupCategories = gradebook.studentGroupFilterMenu.props.items
deepEqual(studentGroupCategories.map(section => section.name), [
'First Group Set',
'Second Group Set'
])
})
test('unescapes student group names', () => {
gradebook.updateStudentGroupFilterVisibility()
const studentGroupCategories = gradebook.studentGroupFilterMenu.props.items
const studentGroups = _.flatten(studentGroupCategories.map(category => category.children))
deepEqual(studentGroups.map(section => section.name), [
'First Group Set 1',
'First Group Set 2',
'Second Group Set 1',
'Second Group Set 2'
])
})
test('sets the student group select to show the saved "filter rows by" setting', () => {
gradebook.setFilterRowsBySetting('studentGroupId', '4')
gradebook.updateStudentGroupFilterVisibility()
strictEqual(gradebook.studentGroupFilterMenu.props.selectedItemId, '4')
})
test('sets the student group select as disabled when students are not loaded', () => {
gradebook.updateStudentGroupFilterVisibility()
strictEqual(gradebook.studentGroupFilterMenu.props.disabled, true)
})
test('sets the section select as not disabled when students are loaded', () => {
gradebook.setStudentsLoaded(true)
gradebook.updateStudentGroupFilterVisibility()
strictEqual(gradebook.studentGroupFilterMenu.props.disabled, false)
})
test('updates the disabled state of the rendered section select', () => {
gradebook.updateStudentGroupFilterVisibility()
gradebook.setStudentsLoaded(true)
gradebook.updateStudentGroupFilterVisibility()
strictEqual(gradebook.studentGroupFilterMenu.props.disabled, false)
})
test('renders only one section select when updated', () => {
gradebook.updateStudentGroupFilterVisibility()
gradebook.updateStudentGroupFilterVisibility()
ok(gradebook.studentGroupFilterMenu, 'student group menu reference has been stored')
strictEqual(container.children.length, 1, 'only one section select is rendered')
})
test('removes the section select when filter is deselected', () => {
gradebook.setSelectedViewOptionsFilters(['assignmentGroups'])
gradebook.updateStudentGroupFilterVisibility()
notOk(gradebook.studentGroupFilterMenu, 'student group menu reference has been stored')
strictEqual(container.children.length, 0, 'nothing was rendered')
})
})
QUnit.module('Menus', {
setup() {
this.gradebook = createGradebook({

View File

@ -43,8 +43,22 @@ QUnit.module('GradebookGrid StudentCellFormatter', hooks => {
{id: '2004', name: 'Seniors'}
])
gradebook.setStudentGroups([
{
groups: [{id: '1', name: 'First Category 1'}, {id: '2', name: 'First Category 2'}],
id: '1',
name: 'First Category'
},
{
groups: [{id: '3', name: 'Second Category 1'}, {id: '4', name: 'Second Category 2'}],
id: '2',
name: 'Second Category'
}
])
student = {
enrollments: [{grades: {html_url: 'http://example.com/grades/1101'}}],
group_ids: ['1', '4'],
id: '1101',
isConcluded: false,
isInactive: false,
@ -154,6 +168,41 @@ QUnit.module('GradebookGrid StudentCellFormatter', hooks => {
equal(renderCell().querySelector('.secondary-info').innerText, student.integration_id)
})
test('renders student group names when secondary info is "group"', () => {
gradebook.setSelectedSecondaryInfo('group')
equal(
renderCell().querySelector('.secondary-info').innerText,
'First Category 1 and Second Category 2'
)
})
test('does not escape html in the student group names', () => {
gradebook.setStudentGroups([
{
groups: [{id: '1', name: '&lt;span&gt;First Category 1&lt;/span&gt;'}],
id: '1',
name: 'First Category'
},
{
groups: [{id: '4', name: 'Second Category 2'}],
id: '1',
name: 'Second Category'
}
])
gradebook.setSelectedSecondaryInfo('group')
equal(
renderCell().querySelector('.secondary-info').innerText,
'&lt;span&gt;First Category 1&lt;/span&gt; and Second Category 2'
)
})
test('does not render student group names when groups should not be visible', () => {
gradebook.setStudentGroups([])
gradebook.setSelectedSecondaryInfo('group')
strictEqual(renderCell().querySelector('.secondary-info'), null)
})
test('does not render secondary info when any secondary info is null and secondary info is not "none"', () => {
student.login_id = null
gradebook.setSelectedSecondaryInfo('login_id', true) // skipRedraw

View File

@ -179,6 +179,23 @@ QUnit.module('GradebookGrid StudentColumnHeaderRenderer', suiteHooks => {
strictEqual(component.props.sectionsEnabled, false)
})
test('sets studentGroupsEnabled to true when student groups are present', () => {
gradebook.setStudentGroups([
{
groups: [{id: '1', name: 'Default Group 1'}, {id: '2', name: 'Default Group 2'}],
id: '1',
name: 'Default Group'
}
])
render()
strictEqual(component.props.studentGroupsEnabled, true)
})
test('sets studentGroupsEnabled to false when student groups are not present', () => {
render()
strictEqual(component.props.studentGroupsEnabled, false)
})
test('includes the selected enrollment filters', () => {
gradebook.toggleEnrollmentFilter('concluded')
render()

View File

@ -63,7 +63,8 @@ QUnit.module('GradebookGrid StudentColumnHeader', suiteHooks => {
onSortBySortableNameAscending() {},
onSortBySortableNameDescending() {},
settingKey: 'sortable_name'
}
},
studentGroupsEnabled: true
}
})
@ -476,6 +477,68 @@ QUnit.module('GradebookGrid StudentColumnHeader', suiteHooks => {
})
})
QUnit.module('"Group" option', () => {
test('is present when the course has student groups', () => {
mountAndOpenOptionsMenu()
ok(getSecondaryInfoOption('Group'))
})
test('is not present when the course has no student groups', () => {
props.studentGroupsEnabled = false
mountAndOpenOptionsMenu()
notOk(getSecondaryInfoOption('Group'))
})
test('is selected when displaying student groups for secondary info', () => {
props.selectedSecondaryInfo = 'group'
mountAndOpenOptionsMenu()
strictEqual(getSecondaryInfoOption('Group').getAttribute('aria-checked'), 'true')
})
test('is not selected when displaying different secondary info', () => {
props.selectedSecondaryInfo = 'sis_id'
mountAndOpenOptionsMenu()
strictEqual(getSecondaryInfoOption('Group').getAttribute('aria-checked'), 'false')
})
QUnit.module('when clicked', contextHooks => {
contextHooks.beforeEach(() => {
props.onSelectSecondaryInfo = sinon.stub()
})
test('calls the .onSelectSecondaryInfo callback', () => {
mountAndOpenOptionsMenu()
getSecondaryInfoOption('Group').click()
strictEqual(props.onSelectSecondaryInfo.callCount, 1)
})
test('includes "group" when calling the .onSelectSecondaryInfo callback', () => {
mountAndOpenOptionsMenu()
getSecondaryInfoOption('Group').click()
const [secondaryInfoType] = props.onSelectSecondaryInfo.lastCall.args
equal(secondaryInfoType, 'group')
})
test('returns focus to the "Options" menu trigger', () => {
mountAndOpenOptionsMenu()
getSecondaryInfoOption('Group').focus()
getSecondaryInfoOption('Group').click()
strictEqual(document.activeElement, getOptionsMenuTrigger())
})
// TODO: GRADE-____
QUnit.skip(
'does not call the .onSelectSecondaryInfo callback when already selected',
() => {
props.selectedSecondaryInfo = 'group'
mountAndOpenOptionsMenu()
getSecondaryInfoOption('Group').click()
strictEqual(props.onSelectSecondaryInfo.callCount, 0)
}
)
})
})
QUnit.module('"SIS ID" option', () => {
test('displays the configured SIS name', () => {
props.sisName = 'Powerschool'

View File

@ -95,3 +95,31 @@ test('selecting an option while the control is disabled does not call the onSele
strictEqual(props.onSelect.callCount, 0)
})
test('options containing child options are rendered as optgroups', function() {
const itemWithChildItems = {
id: '10',
name: 'Hierarchical',
children: [{id: '1001', name: 'Child 1'}, {id: '1002', name: 'Child 2'}]
}
const props = {...defaultProps(), items: [itemWithChildItems]}
this.wrapper = mount(<GradebookFilter {...props} />)
ok(this.wrapper.exists('optgroup[label="Hierarchical"]'))
})
test('child options are rendered within their parent optgroup', function() {
const itemWithChildItems = {
id: '10',
name: 'Hierarchical',
children: [{id: '1001', name: 'Child 1'}, {id: '1002', name: 'Child 2'}]
}
const items = [itemWithChildItems]
const props = {...defaultProps(), items}
this.wrapper = mount(<GradebookFilter {...props} />)
deepEqual(
this.wrapper.find('optgroup[label="Hierarchical"] option').map(option => option.props().value),
['1001', '1002']
)
})

View File

@ -0,0 +1,79 @@
/*
* Copyright (C) 2019 - 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 React from 'react'
import ReactDOM from 'react-dom'
import StudentGroupFilter from 'jsx/gradezilla/default_gradebook/components/StudentGroupFilter'
QUnit.module('Student Group Filter - subclass functionality', hooks => {
let $container
let props
hooks.beforeEach(() => {
$container = document.createElement('div')
document.body.appendChild($container)
props = {
items: [
{
children: [{id: '1', name: 'First Group Set 1'}, {id: '2', name: 'First Group Set 2'}],
id: '1',
name: 'First Group Set'
},
{
children: [{id: '3', name: 'Second Group Set 1'}, {id: '4', name: 'Second Group Set 2'}],
id: '2',
name: 'Second Group Set'
}
],
onSelect: () => {},
selectedItemId: '0'
}
})
hooks.afterEach(() => {
$container.remove()
})
test('renders a screenreader-friendly label', () => {
ReactDOM.render(<StudentGroupFilter {...props} />, $container)
ok($container.querySelector('label').innerText.includes('Student Group Filter'))
})
test('the options are displayed in the same order as they were sent in', () => {
ReactDOM.render(<StudentGroupFilter {...props} />, $container)
// "0" is the value for "All Student Groups"
const expectedOptionValues = ['0', '1', '2', '3', '4']
deepEqual(
[...$container.querySelectorAll('option')].map(option => option.value),
expectedOptionValues
)
})
test('options are displayed within their respective groups', () => {
ReactDOM.render(<StudentGroupFilter {...props} />, $container)
const firstGroupOptions = [
...$container.querySelectorAll('optgroup[label="First Group Set"] option')
]
deepEqual(firstGroupOptions.map(option => option.value), ['1', '2'])
})
})

View File

@ -74,6 +74,7 @@ QUnit.module('Gradebook Enrollment Filter Change Data Loading', suiteHooks => {
},
selected_view_options_filters: []
},
student_groups: [],
students_stateless_url: '/students-url',
submissions_url: '/submissions-url'
}

View File

@ -0,0 +1,433 @@
/*
* Copyright (C) 2019 - 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 DataLoader from 'jsx/gradezilla/DataLoader'
import {
createGradebook,
setFixtureHtml
} from 'jsx/gradezilla/default_gradebook/__tests__/GradebookSpecHelper'
import {createExampleStudents} from './DataLoadingSpecHelpers'
import DataLoadingWrapper from './DataLoadingWrapper'
QUnit.module('Gradebook Student Group Filter Change Data Loading', suiteHooks => {
let $container
let dataLoadingWrapper
let gradebook
let gradebookOptions
let initialData
let nextData
suiteHooks.beforeEach(() => {
$container = document.body.appendChild(document.createElement('div'))
setFixtureHtml($container)
initialData = {
contextModules: [
{id: '2601', position: 3, name: 'English'},
{id: '2602', position: 1, name: 'Math'},
{id: '2603', position: 2, name: 'Science'}
],
gradingPeriodAssignments: {
1401: ['2301'],
1402: ['2302']
},
studentIds: ['1101', '1102', '1103'],
students: createExampleStudents().slice(0, 3)
}
nextData = {
gradingPeriodAssignments: {
1401: ['2301', '2303'],
1402: ['2302', '2304']
},
studentIds: ['1101', '1102', '1103', '1104'],
students: createExampleStudents().slice(3)
}
gradebookOptions = {
api_max_per_page: 50,
assignment_groups_url: '/assignment-groups',
chunk_size: 10,
context_id: '1201',
context_modules_url: '/context-modules',
custom_column_data_url: '/custom-column-data',
sections: [{id: '2001', name: 'Freshmen'}, {id: '2002', name: 'Sophomores'}],
settings: {
filter_rows_by: {
section_id: null
},
selected_view_options_filters: ['sections', 'studentGroups']
},
student_groups: [
{
id: '1',
name: 'Default Set',
groups: [{id: '1', name: 'Default Set 1'}, {id: '2', name: 'Default Set 2'}]
},
{
id: '2',
name: 'Alternate Set',
groups: [{id: '3', name: 'Alternate Set 1'}, {id: '4', name: 'Alternate Set 2'}]
}
],
students_stateless_url: '/students-url',
submissions_url: '/submissions-url'
}
dataLoadingWrapper = new DataLoadingWrapper()
dataLoadingWrapper.setup()
})
suiteHooks.afterEach(() => {
gradebook.destroy()
$container.remove()
dataLoadingWrapper.teardown()
})
function initializeAndLoadGradebook(options = {}) {
if (options.includeGradingPeriodSet) {
gradebookOptions.grading_period_set = {
grading_periods: [
{id: '1401', startDate: new Date('2015-09-01'), title: 'Q1'},
{id: '1402', startDate: new Date('2015-10-15'), title: 'Q2'}
],
id: '1301'
}
}
gradebook = createGradebook(gradebookOptions)
sinon.stub(gradebook, 'saveSettings').callsFake((settings, onSuccess = () => {}) => {
onSuccess(settings)
})
gradebook.initialize()
// Load Grid Data
dataLoadingWrapper.loadStudentIds(initialData.studentIds)
dataLoadingWrapper.loadGradingPeriodAssignments(initialData.gradingPeriodAssignments)
dataLoadingWrapper.loadAssignmentGroups([])
dataLoadingWrapper.loadContextModules()
dataLoadingWrapper.loadCustomColumns()
// Load Student Data
dataLoadingWrapper.loadStudents(initialData.students)
dataLoadingWrapper.loadSubmissions([])
dataLoadingWrapper.finishLoadingStudents()
dataLoadingWrapper.finishLoadingSubmissions()
dataLoadingWrapper.finishLoadingCustomColumnData()
DataLoader.loadGradebookData.resetHistory()
}
function changeStudentGroupFilter() {
gradebook.updateCurrentStudentGroup('3')
}
QUnit.module('when the student group filter changes', () => {
test('sets the students as not loaded', () => {
initializeAndLoadGradebook()
changeStudentGroupFilter()
strictEqual(gradebook.contentLoadStates.studentsLoaded, false)
})
test('sets the submissions as not loaded', () => {
initializeAndLoadGradebook()
changeStudentGroupFilter()
strictEqual(gradebook.contentLoadStates.submissionsLoaded, false)
})
test('calls DataLoader.loadGradebookData()', () => {
initializeAndLoadGradebook()
changeStudentGroupFilter()
strictEqual(DataLoader.loadGradebookData.callCount, 1)
})
QUnit.module('when calling DataLoader.loadGradebookData()', () => {
test('includes the course id', () => {
initializeAndLoadGradebook()
changeStudentGroupFilter()
const [options] = DataLoader.loadGradebookData.lastCall.args
equal(options.courseId, '1201')
})
test('includes the per page api request setting', () => {
initializeAndLoadGradebook()
changeStudentGroupFilter()
const [options] = DataLoader.loadGradebookData.lastCall.args
equal(options.perPage, 50)
})
test('requests assignment groups', () => {
initializeAndLoadGradebook()
changeStudentGroupFilter()
const [options] = DataLoader.loadGradebookData.lastCall.args
equal(typeof options.assignmentGroupsURL, 'undefined')
})
test('does not request context modules', () => {
initializeAndLoadGradebook()
changeStudentGroupFilter()
const [options] = DataLoader.loadGradebookData.lastCall.args
equal(typeof options.contextModulesURL, 'undefined')
})
test('requests data for hidden custom columns', () => {
initializeAndLoadGradebook()
changeStudentGroupFilter()
const [options] = DataLoader.loadGradebookData.lastCall.args
strictEqual(options.customColumnDataParams.include_hidden, true)
})
test('includes the students url', () => {
initializeAndLoadGradebook()
changeStudentGroupFilter()
const [options] = DataLoader.loadGradebookData.lastCall.args
equal(options.studentsURL, '/students-url')
})
test('includes the students page callback', () => {
initializeAndLoadGradebook()
changeStudentGroupFilter()
const [options] = DataLoader.loadGradebookData.lastCall.args
strictEqual(options.studentsPageCb, gradebook.gotChunkOfStudents)
})
test('includes students params', () => {
initializeAndLoadGradebook()
const exampleStudentsParams = {example: true}
sandbox.stub(gradebook, 'studentsParams').returns(exampleStudentsParams)
changeStudentGroupFilter()
const [options] = DataLoader.loadGradebookData.lastCall.args
equal(options.studentsParams, exampleStudentsParams)
})
test('includes the loaded student ids', () => {
initializeAndLoadGradebook()
const studentIds = ['1101', '1102', '1103']
gradebook.courseContent.students.setStudentIds(studentIds)
changeStudentGroupFilter()
const [options] = DataLoader.loadGradebookData.lastCall.args
deepEqual(options.loadedStudentIds, studentIds)
})
test('includes the submissions url', () => {
initializeAndLoadGradebook()
changeStudentGroupFilter()
const [options] = DataLoader.loadGradebookData.lastCall.args
equal(options.submissionsURL, '/submissions-url')
})
test('includes the submissions chunk callback', () => {
initializeAndLoadGradebook()
changeStudentGroupFilter()
const [options] = DataLoader.loadGradebookData.lastCall.args
strictEqual(options.submissionsChunkCb, gradebook.gotSubmissionsChunk)
})
test('includes the submissions chunk size', () => {
initializeAndLoadGradebook()
changeStudentGroupFilter()
const [options] = DataLoader.loadGradebookData.lastCall.args
equal(options.submissionsChunkSize, 10)
})
test('includes the stored custom columns', () => {
initializeAndLoadGradebook()
gradebook.gotCustomColumns([{id: '2401'}, {id: '2402'}])
changeStudentGroupFilter()
const [options] = DataLoader.loadGradebookData.lastCall.args
deepEqual(options.customColumnIds, ['2401', '2402'])
})
test('includes the custom column data url', () => {
initializeAndLoadGradebook()
changeStudentGroupFilter()
const [options] = DataLoader.loadGradebookData.lastCall.args
equal(options.customColumnDataURL, '/custom-column-data')
})
test('includes the custom column data page callback', () => {
initializeAndLoadGradebook()
changeStudentGroupFilter()
const [options] = DataLoader.loadGradebookData.lastCall.args
strictEqual(options.customColumnDataPageCb, gradebook.gotCustomColumnDataChunk)
})
test('does not request grading period assignments', () => {
initializeAndLoadGradebook({includeGradingPeriodSet: true})
changeStudentGroupFilter()
const [options] = DataLoader.loadGradebookData.lastCall.args
strictEqual(options.getGradingPeriodAssignments, false)
})
})
test('re-renders the filters', () => {
initializeAndLoadGradebook()
sandbox.spy(gradebook, 'renderFilters')
changeStudentGroupFilter()
strictEqual(gradebook.renderFilters.callCount, 1)
})
test('re-renders the filters after students load status is updated', () => {
initializeAndLoadGradebook()
sandbox.stub(gradebook, 'renderFilters').callsFake(() => {
strictEqual(gradebook.contentLoadStates.studentsLoaded, false)
})
changeStudentGroupFilter()
})
test('re-renders the filters after submissions load status is updated', () => {
initializeAndLoadGradebook()
sandbox.stub(gradebook, 'renderFilters').callsFake(() => {
strictEqual(gradebook.contentLoadStates.submissionsLoaded, false)
})
changeStudentGroupFilter()
})
QUnit.module('when student ids finish loading', contextHooks => {
contextHooks.beforeEach(() => {
initializeAndLoadGradebook()
changeStudentGroupFilter()
})
test('stores the loaded student ids in the Gradebook', () => {
dataLoadingWrapper.loadStudentIds(nextData.studentIds)
deepEqual(gradebook.courseContent.students.listStudentIds(), nextData.studentIds)
})
test('updates grid rows for the loaded student ids', () => {
dataLoadingWrapper.loadStudentIds(nextData.studentIds)
strictEqual(document.body.querySelectorAll('.canvas_0 .slick-row').length, 4)
})
})
QUnit.module('loading students', hooks => {
hooks.beforeEach(() => {
initializeAndLoadGradebook()
changeStudentGroupFilter()
dataLoadingWrapper.loadStudentIds(nextData.studentIds)
})
QUnit.module('when a chunk of students have loaded', () => {
test('adds the loaded students to the Gradebook', () => {
dataLoadingWrapper.loadStudents(nextData.students)
const studentNames = gradebook.courseContent.students
.listStudents()
.map(student => student.name)
deepEqual(studentNames.sort(), ['Adam Jones', 'Betty Ford', 'Charlie Xi', 'Dana Young'])
})
test('updates the rows for loaded students', () => {
dataLoadingWrapper.loadStudents(nextData.students)
const $studentNames = document.body.querySelectorAll('.slick-row .student-name')
const studentNames = [...$studentNames].map($name => $name.textContent.trim())
deepEqual(studentNames, ['Adam Jones', 'Betty Ford', 'Charlie Xi', 'Dana Young'])
})
})
QUnit.module('when students finish loading', () => {
test('sets the students as loaded', () => {
dataLoadingWrapper.finishLoadingStudents()
strictEqual(gradebook.contentLoadStates.studentsLoaded, true)
})
test('re-renders the column headers', () => {
sinon.spy(gradebook, 'updateColumnHeaders')
dataLoadingWrapper.finishLoadingStudents()
strictEqual(gradebook.updateColumnHeaders.callCount, 1)
})
test('re-renders the column headers after updating students load state', () => {
sinon.stub(gradebook, 'updateColumnHeaders').callsFake(() => {
// students load state was already updated
strictEqual(gradebook.contentLoadStates.studentsLoaded, true)
})
dataLoadingWrapper.finishLoadingStudents()
})
test('re-renders the filters', () => {
sinon.spy(gradebook, 'renderFilters')
dataLoadingWrapper.finishLoadingStudents()
strictEqual(gradebook.renderFilters.callCount, 1)
})
test('re-renders the filters after updating students load state', () => {
sinon.stub(gradebook, 'renderFilters').callsFake(() => {
// students load state was already updated
strictEqual(gradebook.contentLoadStates.studentsLoaded, true)
})
dataLoadingWrapper.finishLoadingStudents()
})
})
})
QUnit.module('loading submissions', hooks => {
hooks.beforeEach(() => {
initializeAndLoadGradebook()
changeStudentGroupFilter()
dataLoadingWrapper.loadStudentIds(nextData.studentIds)
dataLoadingWrapper.loadAssignmentGroups([])
dataLoadingWrapper.loadContextModules()
dataLoadingWrapper.loadCustomColumns()
dataLoadingWrapper.loadStudents(nextData.students)
})
QUnit.module('when submissions finish loading', contextHooks => {
contextHooks.beforeEach(() => {
dataLoadingWrapper.finishLoadingStudents()
})
test('sets the submissions as loaded', () => {
dataLoadingWrapper.finishLoadingSubmissions()
strictEqual(gradebook.contentLoadStates.submissionsLoaded, true)
})
test('re-renders the column headers', () => {
sandbox.spy(gradebook, 'updateColumnHeaders')
dataLoadingWrapper.finishLoadingSubmissions()
strictEqual(gradebook.updateColumnHeaders.callCount, 1)
})
test('re-renders the column headers after updating submissions load state', () => {
sandbox.stub(gradebook, 'updateColumnHeaders').callsFake(() => {
// submissions load state was already updated
strictEqual(gradebook.contentLoadStates.submissionsLoaded, true)
})
dataLoadingWrapper.finishLoadingSubmissions()
})
test('re-renders the filters', () => {
sandbox.spy(gradebook, 'renderFilters')
dataLoadingWrapper.finishLoadingSubmissions()
strictEqual(gradebook.renderFilters.callCount, 1)
})
test('re-renders the filters after updating submissions load state', () => {
sandbox.stub(gradebook, 'renderFilters').callsFake(() => {
// submissions load state was already updated
strictEqual(gradebook.contentLoadStates.submissionsLoaded, true)
})
dataLoadingWrapper.finishLoadingSubmissions()
})
})
})
})
})

View File

@ -82,5 +82,26 @@ describe "Api::V1::GroupCategory" do
end
end
describe "groups within the category" do
let(:course) { Course.create! }
let(:category) { course.group_categories.create!(name: "category") }
let(:user) { course.enroll_teacher(User.create!, enrollment_state: "active").user }
before(:each) do
category.create_groups(2)
end
it "are included when 'groups' is specified as an include key" do
json = CategoryHarness.new.group_category_json(category, user, nil, {include: ['groups']})
json_group_ids = json["groups"].map { |group| group["id"] }
expect(json_group_ids).to match_array(category.groups.pluck(:id))
end
it "are not included when 'groups' is not specified as an include key" do
json = CategoryHarness.new.group_category_json(category, user, nil)
expect(json).not_to have_key("groups")
end
end
end
end

View File

@ -159,6 +159,33 @@ describe GradebookUserIds do
end
end
describe "filtering by student group" do
let_once(:category) do
category = @course.group_categories.create!(name: "whatever")
category.create_groups(2)
category.groups.first.add_user(@student1)
category.groups.second.add_user(@student2)
category
end
let_once(:group) { category.groups.first }
it "only returns students in the selected group when one is specified" do
@teacher.preferences[:gradebook_settings] = {
@course.id => {
filter_rows_by: {
student_group_id: group.id
}
}
}
expect(gradebook_user_ids.user_ids).to contain_exactly(@student1.id)
end
it "returns students in all groups when no group is specified" do
expect(gradebook_user_ids.user_ids).to match_array(@course.students.pluck(:id))
end
end
context 'with pg_collkey installed' do
before do
skip 'requires pg_collkey installed SD-2747' unless has_pg_collkey

View File

@ -116,4 +116,33 @@ describe "Filter" do
expect(Gradezilla::Cells.get_grade(@student_2, @first_assignment)).to eq '1'
end
end
context "by Student Group" do
before(:once) do
gradebook_data_setup
show_student_groups_filter(@teacher)
@category = @course.group_categories.create!(name: "a group category")
@category.create_groups(2)
@category.groups.first.add_user(@student_1)
@category.groups.second.add_user(@student_2)
end
before(:each) { user_session(@teacher) }
it "should allow showing only a specific student group", priority: "1" do
Gradezilla.visit(@course)
Gradezilla.select_student_group("All Student Groups")
Gradezilla::Cells.edit_grade(@student_1, @first_assignment, 0)
Gradezilla::Cells.edit_grade(@student_2, @first_assignment, 1)
group2 = @category.groups.second
Gradezilla.select_student_group(group2)
expect(Gradezilla.student_group_dropdown).to include_text(group2.name)
expect(Gradezilla::Cells.get_grade(@student_2, @first_assignment)).to eq '1'
end
end
end

View File

@ -320,6 +320,12 @@ module Gradezilla
wait_for_ajaximations
end
def self.select_student_group(student_group)
student_group = student_group.name if student_group.is_a?(Group)
click_option(student_group_dropdown, student_group, :text)
wait_for_ajaximations
end
def self.show_notes
view_menu = open_gradebook_menu('View')
select_gradebook_menu_option('Notes', container: view_menu, role: 'menuitemcheckbox')
@ -517,6 +523,10 @@ module Gradezilla
f('#modules-filter-container select')
end
def self.student_group_dropdown
f('#student-group-filter-container select')
end
def self.filter_menu_item(menu_item_name)
fj("option:contains(\"#{menu_item_name}\")")
end

View File

@ -104,6 +104,10 @@ module GradebookSetup
set_filter_visibility(user, 'modules', true)
end
def show_student_groups_filter(user)
set_filter_visibility(user, 'studentGroups', true)
end
def set_filter_visibility(user, filter, visible)
filters = user.preferences.dig(:gradebook_settings, @course.id, :selected_view_options_filters) || []
if visible && !filters.include?(filter)