Add filters for inactive/concluded enrollments to LMGB

flag=inactive_concluded_lmgb_filters

closes OUT-3794

Test-plan:
  * FF: 'Student filters in LMGB'
  - Create 3+ students in a course
  - Create an assignment that has 2+ outcomes attached via rubric
  - For one of the other students, conclude their enrollment
    ('Concluded User')
  - For one of the other students, deactivate their user
    ('Deactivated User')
  - One user should remain that is an active enrollment
    ('Normal User')
  - Provide outcomes assessment on the new assignment via speedgrader
     for each of the above 3 students (Concluded/Deactivated/Normal)
  - Create another 20 students in the course (they don't have to have
    assessments)
    > 1.upto(20) {|i| u = User.create!(name: "Student #{i}");
        Course.find(..).enroll_student(u) }

  - With the FF off
    - LMGB should behave as normal
    - Note: if "Hide students with unassessed outcomes" is enabled,
      Unassessed User should not appear in the outcome report and
      vice versa.

  - With the FF on
    - Click kebab filter in 'Students' header to toggle different
      options
    - For 'Show Unassessed students' enabled, unassessed users should
      appear and vice versa
    - For 'Show Concluded students' enabled, Concluded User should appear
      and vice versa
    - For 'Show Inactive students' enabled, Inactive User should appear
      and vice versa
    - The filter options should now apply to the export, i.e. the users
      that appear in the LMGB should appear in the CSV export (sorting will
      differ)
    - Verify that sorting by column headers works as expected
    - Verify that the column header averages apply only to the scores
      that are visible (total students, not limited to the current page)
    - Verify that pagination is correct (20 students by default) regardless
      of filter selections

  - Add an inactive and concluded user to a different section
  - Change a users' section and make sure that with the FF on and off,
    the LMGB behaves as expected.
  - Note: inactive + concluded users
    should be displayed in CSV with FF disabled but not in the UI.
    Unassessed users with active enrollments should always appear in the
    csv report if and only if they appear in the UI based on enabled or
    disabled filters.

Change-Id: I5639dc28f1c5ebd43900de4e99006ecc3535468d
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/256110
QA-Review: Augusto Callejas <acallejas@instructure.com>
Product-Review: Jody Sailor
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Augusto Callejas <acallejas@instructure.com>
Reviewed-by: Pat Renner <prenner@instructure.com>
This commit is contained in:
Brian Watson 2020-12-22 08:03:29 -07:00
parent ec0cbda3bf
commit 9a5bc16cbd
16 changed files with 777 additions and 31 deletions

View File

@ -69,6 +69,8 @@ export default class OutcomeGradebookView extends View
new CheckboxView(Dictionary.remedial)
]
inactive_concluded_lmgb_filters: ENV.GRADEBOOK_OPTIONS?.inactive_concluded_lmgb_filters
ratings: []
events:
@ -84,6 +86,7 @@ export default class OutcomeGradebookView extends View
if ENV.GRADEBOOK_OPTIONS.outcome_proficiency?.ratings
@ratings = ENV.GRADEBOOK_OPTIONS.outcome_proficiency.ratings
@checkboxes = @ratings.map (rating) -> new CheckboxView({color: "\##{rating.color}", label: rating.description})
Grid.gridRef = @
remove: ->
super()
@ -146,7 +149,8 @@ export default class OutcomeGradebookView extends View
view.on('togglestate', @_createFilter("rating_#{i}")) for view, i in @checkboxes
@updateExportLink(@learningMastery.getCurrentSectionId())
@$('#no_results_outcomes').change(() -> _this._toggleOutcomesWithNoResults(this.checked))
@$('#no_results_students').change(() -> _this._toggleStudentsWithNoResults(this.checked))
if !@inactive_concluded_lmgb_filters
@$('#no_results_students').change(() -> _this._toggleStudentsWithNoResults(this.checked))
_setFilterSetting: (name, value) ->
filters = userSettings.contextGet('lmgb_filters')
@ -167,8 +171,19 @@ export default class OutcomeGradebookView extends View
@grid.setColumns(@columns)
Grid.View.redrawHeader(@grid, Grid.averageFn)
_toggleStudentsWithNoResults: (enabled) ->
_toggleStudentsWithNoResults: (enabled) =>
@_setFilterSetting('students_no_results', enabled)
@updateExportLink(@learningMastery.getCurrentSectionId())
@_rerender()
_toggleStudentsWithInactiveEnrollments: (enabled) =>
@_setFilterSetting('inactive_enrollments', enabled)
@updateExportLink(@learningMastery.getCurrentSectionId())
@_rerender()
_toggleStudentsWithConcludedEnrollments: (enabled) =>
@_setFilterSetting('concluded_enrollments', enabled)
@updateExportLink(@learningMastery.getCurrentSectionId())
@_rerender()
_rerender: ->
@ -179,7 +194,11 @@ export default class OutcomeGradebookView extends View
@_loadOutcomes()
_toggleSort: (e, {grid, sortAsc, sortCol}) =>
if sortCol.field == @sortField
target = $(e.target).attr('data-component')
# Don't sort if user clicks the enrollments filter kabob
if target == 'lmgb-student-filter-trigger'
return
else if sortCol.field == @sortField
# Change sort direction
@sortOrderAsc = !@sortOrderAsc
else
@ -200,11 +219,12 @@ export default class OutcomeGradebookView extends View
#
# Returns an object.
toJSON: ->
_.extend({}, checkboxes: @checkboxes)
_.extend({}, checkboxes: @checkboxes, inactive_concluded_lmgb_filters: @inactive_concluded_lmgb_filters)
_loadFilterSettings: ->
@$('#no_results_outcomes').prop('checked', @._getFilterSetting('outcomes_no_results'))
@$('#no_results_students').prop('checked', @._getFilterSetting('students_no_results'))
if !@inactive_concluded_lmgb_filters
@$('#no_results_students').prop('checked', @._getFilterSetting('students_no_results'))
# Public: Render the view once all needed data is loaded.
#
@ -308,10 +328,24 @@ export default class OutcomeGradebookView extends View
"/api/v1/courses/#{course}/outcome_rollups?rating_percents=true&per_page=20&include[]=outcomes&include[]=users&include[]=outcome_paths#{excluding}&page=#{page}#{sortParams}#{sectionParam}"
_loadOutcomes: (page = 1) =>
exclude = if @$('#no_results_students').prop('checked') then 'missing_user_rollups' else ''
filter = @_getOutcomeFiltersParams()
course = ENV.context_asset_string.split('_')[1]
@$('.outcome-gradebook-wrapper').disableWhileLoading(@hasOutcomes)
@_loadPage(@_rollupsUrl(course, exclude, page))
@_loadPage(@_rollupsUrl(course, filter, page))
_getOutcomeFilters: ->
outcome_filters = []
if @inactive_concluded_lmgb_filters
if !@._getFilterSetting('inactive_enrollments') then outcome_filters.push('inactive_enrollments')
if !@._getFilterSetting('concluded_enrollments') then outcome_filters.push('concluded_enrollments')
if !@._getFilterSetting('students_no_results') then outcome_filters.push('missing_user_rollups')
else
if @._getFilterSetting('students_no_results') then outcome_filters.push('missing_user_rollups')
return outcome_filters
_getOutcomeFiltersParams: ->
return @_getOutcomeFilters().map((value) => "&exclude[]=#{value}").join('')
# Internal: Load a page of outcome results from the given URL.
#
@ -364,5 +398,11 @@ export default class OutcomeGradebookView extends View
updateExportLink: (section) =>
url = "#{ENV.GRADEBOOK_OPTIONS.context_url}/outcome_rollups.csv"
url += "?section_id=#{section}" if section and section != '0'
params = "#{@_getOutcomeFiltersParams()}"
if section and section != '0'
params += "&" if params != ''
params += "section_id=#{section}"
url += "?#{params}" if params != ''
$('.export-content').attr('href', url)

View File

@ -609,7 +609,8 @@ class GradebooksController < ApplicationController
def set_learning_mastery_env
set_student_context_cards_js_env
visible_sections = if @context.root_account.feature_enabled?(:limit_section_visibility_in_lmgb)
root_account = @context.root_account
visible_sections = if root_account.feature_enabled?(:limit_section_visibility_in_lmgb)
@context.sections_visible_to(@current_user)
else
@context.active_course_sections
@ -619,11 +620,12 @@ class GradebooksController < ApplicationController
GRADEBOOK_OPTIONS: {
context_id: @context.id.to_s,
context_url: named_context_url(@context, :context_url),
ACCOUNT_LEVEL_MASTERY_SCALES: @context.root_account.feature_enabled?(:account_level_mastery_scales),
ACCOUNT_LEVEL_MASTERY_SCALES: root_account.feature_enabled?(:account_level_mastery_scales),
outcome_proficiency: outcome_proficiency,
sections: sections_json(visible_sections, @current_user, session, [], allow_sis_ids: true),
settings: gradebook_settings(@context.global_id),
settings_update_url: api_v1_course_gradebook_settings_update_url(@context)
settings_update_url: api_v1_course_gradebook_settings_update_url(@context),
inactive_concluded_lmgb_filters: root_account.feature_enabled?(:inactive_concluded_lmgb_filters)
}
})
end

View File

@ -347,10 +347,33 @@ class OutcomeResultsController < ApplicationController
def user_rollups(opts = {})
excludes = Api.value_to_array(params[:exclude]).uniq
filter_users_by_excludes
@results = find_results(opts).preload(:user)
outcome_results_rollups(results: @results, users: @users, excludes: excludes, context: @context)
end
def filter_users_by_excludes(aggregate = false)
excludes = Api.value_to_array(params[:exclude]).uniq
# exclude users with no results (if being requested) before we paginate,
# otherwise we end up with users in the pagination that may have no rollups,
# which will inflate the pagination total count
remove_users_with_no_results if excludes.include?('missing_user_rollups') && !aggregate
if @context.root_account.feature_enabled?(:inactive_concluded_lmgb_filters)
exclude_concluded = excludes.include? 'concluded_enrollments'
exclude_inactive = excludes.include? 'inactive_enrollments'
return unless exclude_concluded || exclude_inactive
filters = []
filters << 'completed' if exclude_concluded
filters << 'inactive' if exclude_inactive
ActiveRecord::Associations::Preloader.new.preload(@users, :enrollments)
@users = @users.reject {|u| u.enrollments.all? {|e| filters.include? e.workflow_state}}
end
end
def remove_users_with_no_results
userids_with_results = find_results.pluck(:user_id).uniq
@users = @users.select { |u| userids_with_results.include? u.id }
@ -358,13 +381,9 @@ class OutcomeResultsController < ApplicationController
def user_rollups_json
return user_rollups_sorted_by_score_json if params[:sort_by] == 'outcome' && params[:sort_outcome_id]
excludes = Api.value_to_array(params[:exclude]).uniq
# exclude users with no results (if being requested) before we paginate,
# otherwise we end up with users in the pagination that may have no rollups,
# which will inflate the pagination total count
remove_users_with_no_results if excludes.include? 'missing_user_rollups'
@users = Api.paginate(@users, self, api_v1_course_outcome_rollups_url(@context))
rollups = user_rollups
@users = Api.paginate(@users, self, api_v1_course_outcome_rollups_url(@context))
rollups = @users.map {|u| rollups.find {|r| r.context.id == u.id }}.compact if params[:sort_by] == 'student'
json = outcome_results_rollups_json(rollups)
json[:meta] = Api.jsonapi_meta(@users, self, api_v1_course_outcome_rollups_url(@context))
@ -397,7 +416,8 @@ class OutcomeResultsController < ApplicationController
def aggregate_rollups_json
# calculating averages for all users in the context and only returning one
# rollup, so don't paginate users in this method.
@results = find_results.preload(:user)
filter_users_by_excludes(true)
@results = find_results(all_users: false).preload(:user)
aggregate_rollups = [aggregate_outcome_results_rollup(@results, @context, params[:aggregate_stat])]
json = aggregate_outcome_results_rollups_json(aggregate_rollups)
# no pagination, so no meta field
@ -572,7 +592,12 @@ class OutcomeResultsController < ApplicationController
elsif params[:section_id]
@section = @context.course_sections.where(id: params[:section_id].to_i).first
reject! "invalid section id" unless @section
@users = apply_sort_order(@section.students).to_a
@users = if @context.root_account.feature_enabled?(:inactive_concluded_lmgb_filters)
# include all enrollment types which will be filtered later
apply_sort_order(@section.users).to_a
else
apply_sort_order(@section.students).to_a
end
end
@users ||= users_for_outcome_context.to_a
@users.sort! {|a,b| a.id <=> b.id} unless params[:sort_by]

View File

@ -20,10 +20,14 @@ import I18n from 'i18n!gradebookOutcomeGradebookGrid'
import $ from 'jquery'
import _ from 'underscore'
import HeaderFilterView from 'jsx/gradebook/views/HeaderFilterView'
import OutcomeFilterView from 'jsx/gradebook/views/OutcomeFilterView'
import OutcomeColumnView from 'compiled/views/gradebook/OutcomeColumnView'
import cellTemplate from 'jst/gradebook/outcome_gradebook_cell'
import studentCellTemplate from 'jst/gradebook/outcome_gradebook_student_cell'
import React from 'react'
import ReactDOM from 'react-dom'
/*
xsslint safeString.method cellHtml
*/
@ -35,6 +39,7 @@ const Grid = {
section: undefined,
dataSource: {},
outcomes: [],
gridRef: null,
options: {
headerRowHeight: 42,
rowHeight: 42,
@ -348,6 +353,7 @@ const Grid = {
})
}
},
// This only renders student rows, not column headers
studentCell(_row, _cell, value, _columnDef, _dataContext) {
return studentCellTemplate(
_.extend(value, {
@ -403,10 +409,6 @@ const Grid = {
if (column.field === 'student') {
return Grid.View.studentHeaderRowCell(node, column, grid)
}
const results = Grid.View.getColumnResults(grid.getData(), column)
if (!results.length) {
return $(node).empty()
}
return $(node)
.empty()
.append(Grid.View.cellHtml(score?.score, score?.hide_points, column, false))
@ -414,7 +416,10 @@ const Grid = {
_aggregateUrl(stat) {
const course = ENV.context_asset_string.split('_')[1]
const sectionParam = Grid.section && Grid.section !== '0' ? `&section_id=${Grid.section}` : ''
return `/api/v1/courses/${course}/outcome_rollups?aggregate=course&aggregate_stat=${stat}${sectionParam}`
const filters = Grid.gridRef._getOutcomeFiltersParams()
? `${Grid.gridRef._getOutcomeFiltersParams()}`
: ''
return `/api/v1/courses/${course}/outcome_rollups?aggregate=course&aggregate_stat=${stat}${sectionParam}${filters}`
},
redrawHeader(grid, fn = Grid.averageFn) {
Grid.averageFn = fn
@ -442,6 +447,22 @@ const Grid = {
})
})
},
addEnrollmentFilters(node) {
const existingExclusions = Grid.gridRef._getOutcomeFilters()
const menu = React.createElement(
OutcomeFilterView,
{
showInactiveEnrollments: !existingExclusions.includes('inactive_enrollments'),
showConcludedEnrollments: !existingExclusions.includes('concluded_enrollments'),
showUnassessedStudents: !existingExclusions.includes('missing_user_rollups'),
toggleInactiveEnrollments: Grid.gridRef._toggleStudentsWithInactiveEnrollments,
toggleConcludedEnrollments: Grid.gridRef._toggleStudentsWithConcludedEnrollments,
toggleUnassessedStudents: Grid.gridRef._toggleStudentsWithNoResults
},
null
)
ReactDOM.render(menu, node)
},
studentHeaderRowCell(node, _column, grid) {
$(node).addClass('average-filter')
const view = new HeaderFilterView({
@ -453,6 +474,10 @@ const Grid = {
},
headerCell({node, column, grid}, _fn = Grid.averageFn) {
if (column.field === 'student') {
if (ENV.GRADEBOOK_OPTIONS?.inactive_concluded_lmgb_filters) {
$(node).empty()
this.addEnrollmentFilters(node)
}
return
}
const totalsFn = _.partial(Grid.View.calculateRatingsTotals, grid, column)

View File

@ -22,6 +22,12 @@ import 'compiled/jquery.kylemenu'
import template from 'jst/gradebook/header_filter'
class HeaderFilterView extends View {
toJSON() {
return {
inactive_concluded_lmgb_filters: ENV.GRADEBOOK_OPTIONS?.inactive_concluded_lmgb_filters
}
}
onClick(e) {
e.preventDefault()
e.stopPropagation()

View File

@ -0,0 +1,112 @@
/*
* Copyright (C) 2017 - 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 {func, bool} from 'prop-types'
import {IconMoreSolid} from '@instructure/ui-icons'
import {IconButton} from '@instructure/ui-buttons'
import {View} from '@instructure/ui-layout'
import {Menu} from '@instructure/ui-menu'
import {Text} from '@instructure/ui-elements'
import I18n from 'i18n!gradebook'
import {Flex} from '@instructure/ui-flex'
export default class StudentColumnHeader extends React.Component {
static propTypes = {
showInactiveEnrollments: bool.isRequired,
showConcludedEnrollments: bool.isRequired,
showUnassessedStudents: bool.isRequired,
toggleInactiveEnrollments: func.isRequired,
toggleConcludedEnrollments: func.isRequired,
toggleUnassessedStudents: func.isRequired
}
toggleInactiveEnrollments = (_e, _menuItem, newValue) => {
this.props.toggleInactiveEnrollments(newValue)
}
toggleConcludedEnrollments = (_e, _menuItem, newValue) => {
this.props.toggleConcludedEnrollments(newValue)
}
toggleUnassessedStudents = (_e, _menuItem, newValue) => {
this.props.toggleUnassessedStudents(newValue)
}
render() {
return (
<View textAlign="end">
<Flex id="learning-mastery-gradebook-filter">
<Flex.Item shouldGrow>
<Text weight="bold" id="lmgb-student-filter-title">
{I18n.t('Students')}
</Text>
</Flex.Item>
<Flex.Item shouldShrink id="lmgb-student-filter-trigger">
<Menu
placement="bottom end"
withArrow
shouldHideOnSelect
trigger={
<IconButton
data-component="lmgb-student-filter-trigger"
renderIcon={IconMoreSolid}
withBackground={false}
withBorder={false}
size="medium"
screenReaderLabel={I18n.t('Display Filter Options')}
/>
}
>
<Menu.Group
allowMultiple
label={I18n.t('Show')}
id="learning-mastery-gradebook-dropdown"
>
<Menu.Item
value="display_inactive_enrollments"
data-component="lmgb-student-filter-inactive-enrollments"
selected={this.props.showInactiveEnrollments}
onSelect={this.toggleInactiveEnrollments}
>
{I18n.t('Inactive enrollments')}
</Menu.Item>
<Menu.Item
value="display_concluded_enrollments"
data-component="lmgb-student-filter-concluded-enrollments"
selected={this.props.showConcludedEnrollments}
onSelect={this.toggleConcludedEnrollments}
>
{I18n.t('Concluded enrollments')}
</Menu.Item>
<Menu.Item
value="no_results_students"
data-component="lmgb-student-filter-unassessed-students"
selected={this.props.showUnassessedStudents}
onSelect={this.toggleUnassessedStudents}
>
{I18n.t('Unassessed students')}
</Menu.Item>
</Menu.Group>
</Menu>
</Flex.Item>
</Flex>
</View>
)
}
}

View File

@ -0,0 +1,104 @@
/*
* Copyright (C) 2020 - 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 {render, fireEvent} from '@testing-library/react'
import OutcomeFilterView from '../OutcomeFilterView'
const defaultProps = () => ({
showInactiveEnrollments: false,
showConcludedEnrollments: false,
showUnassessedStudents: false,
toggleInactiveEnrollments: () => {},
toggleConcludedEnrollments: () => {},
toggleUnassessedStudents: () => {}
})
it('calls toggleInactiveEnrollments to enable displaying inactive enrollments', () => {
const toggleInactiveEnrollments = jest.fn()
const {getByText, getByRole} = render(
<OutcomeFilterView {...defaultProps()} toggleInactiveEnrollments={toggleInactiveEnrollments} />
)
fireEvent.click(getByRole('button'))
fireEvent.click(getByText('Inactive enrollments'))
expect(toggleInactiveEnrollments).toHaveBeenCalledWith(true)
})
it('calls toggleInactiveEnrollments to disable displaying inactive enrollments', () => {
const toggleInactiveEnrollments = jest.fn()
const {getByText, getByRole} = render(
<OutcomeFilterView
{...defaultProps()}
toggleInactiveEnrollments={toggleInactiveEnrollments}
showInactiveEnrollments
/>
)
fireEvent.click(getByRole('button'))
fireEvent.click(getByText('Inactive enrollments'))
expect(toggleInactiveEnrollments).toHaveBeenCalledWith(false)
})
it('calls toggleConcludedEnrollments to enable displaying Concluded enrollments', () => {
const toggleConcludedEnrollments = jest.fn()
const {getByText, getByRole} = render(
<OutcomeFilterView
{...defaultProps()}
toggleConcludedEnrollments={toggleConcludedEnrollments}
/>
)
fireEvent.click(getByRole('button'))
fireEvent.click(getByText('Concluded enrollments'))
expect(toggleConcludedEnrollments).toHaveBeenCalledWith(true)
})
it('calls toggleConcludedEnrollments to disable displaying Concluded enrollments', () => {
const toggleConcludedEnrollments = jest.fn()
const {getByText, getByRole} = render(
<OutcomeFilterView
{...defaultProps()}
toggleConcludedEnrollments={toggleConcludedEnrollments}
showConcludedEnrollments
/>
)
fireEvent.click(getByRole('button'))
fireEvent.click(getByText('Concluded enrollments'))
expect(toggleConcludedEnrollments).toHaveBeenCalledWith(false)
})
it('calls toggleUnassessedStudents to enable displaying Unassessed students', () => {
const toggleUnassessedStudents = jest.fn()
const {getByText, getByRole} = render(
<OutcomeFilterView {...defaultProps()} toggleUnassessedStudents={toggleUnassessedStudents} />
)
fireEvent.click(getByRole('button'))
fireEvent.click(getByText('Unassessed students'))
expect(toggleUnassessedStudents).toHaveBeenCalledWith(true)
})
it('calls toggleUnassessedStudents to disable displaying Unassessed students', () => {
const toggleUnassessedStudents = jest.fn()
const {getByText, getByRole} = render(
<OutcomeFilterView
{...defaultProps()}
toggleUnassessedStudents={toggleUnassessedStudents}
showUnassessedStudents
/>
)
fireEvent.click(getByRole('button'))
fireEvent.click(getByText('Unassessed students'))
expect(toggleUnassessedStudents).toHaveBeenCalledWith(false)
})

View File

@ -367,3 +367,11 @@ $page-dark: #ebeff2;
&.near-mastery { background: $near-color; }
&.remedial { background: $remedial-color; }
}
#learning-mastery-gradebook-filter {
margin-#{direction(right)}: 3px;
}
#lmgb-average-filter {
margin-#{direction(right)}: 10px;
}

View File

@ -1,5 +1,5 @@
<div class="inline-block">
<a class="al-trigger" href="#" data-append-to-body="true" role="button">
<a class="al-trigger" href="#" data-append-to-body="true" role="button" {{#if inactive_concluded_lmgb_filters}}id="lmgb-average-filter"{{/if}}>
<span class="current-label">{{#t "course_average"}}Course average{{/t}}</span>
<i class="icon-mini-arrow-down"></i>
</a>

View File

@ -16,10 +16,12 @@
<input type="checkbox" id="no_results_outcomes"/>
<label for="no_results_outcomes">{{#t}}Hide outcomes with no results{{/t}}</label>
</li>
<li>
<input type="checkbox" id="no_results_students"/>
<label for="no_results_students">{{#t}}Hide students with no results{{/t}}</label>
</li>
{{#unless inactive_concluded_lmgb_filters}}
<li>
<input type="checkbox" id="no_results_students"/>
<label for="no_results_students">{{#t}}Hide students with no results{{/t}}</label>
</li>
{{/unless}}
</ul>
<ul class="operations unstyled" >
<li class="operation-view">

View File

@ -55,3 +55,8 @@ improved_outcomes_management:
display_name: Improved Outcomes Management
description: Helps administrators and teachers make more meaningful decisions as they import,
organize, and edit outcomes in their account and courses.
inactive_concluded_lmgb_filters:
state: hidden
applies_to: RootAccount
display_name: Student filters in LMGB
description: Allow inactive and/or concluded enrollments to be displayed in LMGB

View File

@ -189,7 +189,11 @@ module Api::V1::OutcomeResults
section_ids_func = if @section
->(user) { [@section.id] }
else
enrollments = @context.student_enrollments.active.where(:user_id => serialized_rollup_pairs.map{|pair| pair[0].context.id}).to_a
enrollments = if @context.root_account.feature_enabled?(:inactive_concluded_lmgb_filters)
@context.all_accepted_student_enrollments.where(:user_id => serialized_rollup_pairs.map{|pair| pair[0].context.id}).to_a
else
@context.student_enrollments.active.where(:user_id => serialized_rollup_pairs.map{|pair| pair[0].context.id}).to_a
end
->(user) { enrollments.select{|e| e.user_id == user.id}.map(&:course_section_id) }
end

View File

@ -315,6 +315,235 @@ describe "Outcome Results API", type: :request do
END
end
end
# rubocop:disable RSpec/NestedGroups
context 'Student filters in LMGB FF' do
before do
@concluded_student = User.create!(:name => 'Student - Concluded')
@concluded_student.register!
@course.enroll_student(@concluded_student)
create_outcome_assessment(student: @concluded_student)
@concluded_student.enrollments.first.conclude
@inactive_student = User.create!(:name => 'Student - Inactive')
@inactive_student.register!
@course.enroll_student(@inactive_student)
create_outcome_assessment(student: @inactive_student)
@inactive_student.enrollments.first.deactivate
@no_results_student = User.create!(:name => 'Student - No Results')
@no_results_student.register!
@course.enroll_student(@no_results_student)
outcome_result # Creates result for enrolled student outcome_student
end
context 'is disabled' do
it "doesn't display no results students when exclude[]=missing_user_rollups is present" do
user_session @user
get "/courses/#{@course.id}/outcome_rollups.csv?exclude[]=missing_user_rollups"
expect(response).to be_successful
expect(response.body).to eq <<~CSV
Student name,Student ID,new outcome result,new outcome mastery points
#{@concluded_student.name},#{@concluded_student.id},3.0,3.0
#{@inactive_student.name},#{@inactive_student.id},3.0,3.0
#{outcome_student.name},#{outcome_student.id},3.0,3.0
CSV
end
it "does display no results students when no excludes are present" do
user_session @user
get "/courses/#{@course.id}/outcome_rollups.csv?"
expect(response).to be_successful
expect(response.body).to eq <<~CSV
Student name,Student ID,new outcome result,new outcome mastery points
#{@concluded_student.name},#{@concluded_student.id},3.0,3.0
#{@inactive_student.name},#{@inactive_student.id},3.0,3.0
#{outcome_student.name},#{outcome_student.id},3.0,3.0
#{@no_results_student.name},#{@no_results_student.id},,3.0
CSV
end
it 'does not display concluded students in a specific section' do
section1 = add_section 's1', course: outcome_course
student_in_section section1, user: @concluded_student, allow_multiple_enrollments: true
@concluded_student.enrollments.map(&:deactivate)
user_session @user
get "/courses/#{@course.id}/outcome_rollups.csv?section_id=#{section1.id}"
expect(response).to be_successful
expect(response.body).to eq <<~CSV
Student name,Student ID,new outcome result,new outcome mastery points
CSV
end
it 'does not display inactive students in a specific section' do
section1 = add_section 's1', course: outcome_course
student_in_section section1, user: @inactive_student, allow_multiple_enrollments: true
@inactive_student.enrollments.map(&:deactivate)
user_session @user
get "/courses/#{@course.id}/outcome_rollups.csv?section_id=#{section1.id}"
expect(response).to be_successful
expect(response.body).to eq <<~CSV
Student name,Student ID,new outcome result,new outcome mastery points
CSV
end
end
context 'is enabled' do
before do
@course.account.enable_feature!(:inactive_concluded_lmgb_filters)
end
it "doesn't display concluded students when exclude[]=concluded_enrollments is present" do
user_session @user
get "/courses/#{@course.id}/outcome_rollups.csv?exclude[]=concluded_enrollments"
expect(response).to be_successful
expect(response.body).to eq <<~CSV
Student name,Student ID,new outcome result,new outcome mastery points
#{@inactive_student.name},#{@inactive_student.id},3.0,3.0
#{outcome_student.name},#{outcome_student.id},3.0,3.0
#{@no_results_student.name},#{@no_results_student.id},,3.0
CSV
end
it "doesn't display concluded students when exclude[]=concluded_enrollments and section_id is given" do
section1 = add_section 's1', course: outcome_course
student_in_section section1, user: outcome_student, allow_multiple_enrollments: true
student_in_section section1, user: @concluded_student, allow_multiple_enrollments: true
@concluded_student.enrollments.map(&:conclude)
user_session @user
get "/courses/#{@course.id}/outcome_rollups.csv?exclude[]=concluded_enrollments&section_id=#{section1.id}"
expect(response).to be_successful
expect(response.body).to eq <<~CSV
Student name,Student ID,new outcome result,new outcome mastery points
#{outcome_student.name},#{outcome_student.id},3.0,3.0
CSV
end
it "doesn't display inactive students when exclude[]=inactive_enrollments is present" do
user_session @user
get "/courses/#{@course.id}/outcome_rollups.csv?exclude[]=inactive_enrollments"
expect(response).to be_successful
expect(response.body).to eq <<~CSV
Student name,Student ID,new outcome result,new outcome mastery points
#{@concluded_student.name},#{@concluded_student.id},3.0,3.0
#{outcome_student.name},#{outcome_student.id},3.0,3.0
#{@no_results_student.name},#{@no_results_student.id},,3.0
CSV
end
it "doesn't display concluded students when exclude[]=inactive_enrollments and section_id is given" do
section1 = add_section 's1', course: outcome_course
student_in_section section1, user: outcome_student, allow_multiple_enrollments: true
student_in_section section1, user: @inactive_student, allow_multiple_enrollments: true
@inactive_student.enrollments.map(&:deactivate)
user_session @user
get "/courses/#{@course.id}/outcome_rollups.csv?exclude[]=inactive_enrollments&section_id=#{section1.id}"
expect(response).to be_successful
expect(response.body).to eq <<~CSV
Student name,Student ID,new outcome result,new outcome mastery points
#{outcome_student.name},#{outcome_student.id},3.0,3.0
CSV
end
it "doesn't display no results students when exclude[]=missing_user_rollups is present" do
user_session @user
get "/courses/#{@course.id}/outcome_rollups.csv?exclude[]=missing_user_rollups"
expect(response).to be_successful
expect(response.body).to eq <<~CSV
Student name,Student ID,new outcome result,new outcome mastery points
#{@concluded_student.name},#{@concluded_student.id},3.0,3.0
#{@inactive_student.name},#{@inactive_student.id},3.0,3.0
#{outcome_student.name},#{outcome_student.id},3.0,3.0
CSV
end
it 'displays concluded, inactive, and no results students when they are not excluded' do
user_session @user
get "/courses/#{@course.id}/outcome_rollups.csv"
expect(response).to be_successful
expect(response.body).to eq <<~CSV
Student name,Student ID,new outcome result,new outcome mastery points
#{@concluded_student.name},#{@concluded_student.id},3.0,3.0
#{@inactive_student.name},#{@inactive_student.id},3.0,3.0
#{outcome_student.name},#{outcome_student.id},3.0,3.0
#{@no_results_student.name},#{@no_results_student.id},,3.0
CSV
end
context 'users with multiple enrollments' do
before do
@section1 = add_section 's1', course: outcome_course
student_in_section @section1, user: outcome_student, allow_multiple_enrollments: true
student_in_section @section1, user: @inactive_student, allow_multiple_enrollments: true
student_in_section @section1, user: @concluded_student, allow_multiple_enrollments: true
end
it 'displays concluded, inactive, and no results students when they arent excluded in a section' do
student_in_section @section1, user: @no_results_student, allow_multiple_enrollments: true
@no_results_student.enrollments.first.accept
@inactive_student.enrollments.map(&:deactivate)
@concluded_student.enrollments.map(&:conclude)
user_session @user
get "/courses/#{@course.id}/outcome_rollups.csv?section_id=#{@section1.id}"
expect(response).to be_successful
expect(response.body).to eq <<~CSV
Student name,Student ID,new outcome result,new outcome mastery points
#{@concluded_student.name},#{@concluded_student.id},3.0,3.0
#{@inactive_student.name},#{@inactive_student.id},3.0,3.0
#{outcome_student.name},#{outcome_student.id},3.0,3.0
#{@no_results_student.name},#{@no_results_student.id},,3.0
CSV
end
it 'students with an active enrollment are always present' do
user_session @user
get "/courses/#{@course.id}/outcome_rollups.csv?exclude[]=inactive_enrollments&exclude[]=concluded_enrollments"
expect(response).to be_successful
expect(response.body).to eq <<~CSV
Student name,Student ID,new outcome result,new outcome mastery points
#{@concluded_student.name},#{@concluded_student.id},3.0,3.0
#{@inactive_student.name},#{@inactive_student.id},3.0,3.0
#{outcome_student.name},#{outcome_student.id},3.0,3.0
#{@no_results_student.name},#{@no_results_student.id},,3.0
CSV
end
it 'users with inactive and concluded enrollments do display when only one is excluded' do
@inactive_student.enrollments.last.conclude
@concluded_student.enrollments.last.deactivate
user_session @user
get "/courses/#{@course.id}/outcome_rollups.csv?exclude[]=inactive_enrollments"
expect(response).to be_successful
expect(response.body).to eq <<~CSV
Student name,Student ID,new outcome result,new outcome mastery points
#{@concluded_student.name},#{@concluded_student.id},3.0,3.0
#{@inactive_student.name},#{@inactive_student.id},3.0,3.0
#{outcome_student.name},#{outcome_student.id},3.0,3.0
#{@no_results_student.name},#{@no_results_student.id},,3.0
CSV
end
it 'users with inactive and concluded enrollments dont display when both are excluded' do
@inactive_student.enrollments.last.conclude
@concluded_student.enrollments.last.deactivate
user_session @user
get "/courses/#{@course.id}/outcome_rollups.csv?exclude[]=inactive_enrollments&exclude[]=concluded_enrollments"
expect(response).to be_successful
expect(response.body).to eq <<~CSV
Student name,Student ID,new outcome result,new outcome mastery points
#{outcome_student.name},#{outcome_student.id},3.0,3.0
#{@no_results_student.name},#{@no_results_student.id},,3.0
CSV
end
end
end
end
# rubocop:enable RSpec/NestedGroups
end
describe "user_ids parameter" do

View File

@ -1563,6 +1563,22 @@ describe GradebooksController do
end
end
end
describe 'inactive_concluded_lmgb_filters' do
it 'is false if the feature flag is off' do
@course.root_account.disable_feature! :inactive_concluded_lmgb_filters
get :show, params: {course_id: @course.id}
gradebook_env = assigns[:js_env][:GRADEBOOK_OPTIONS]
expect(gradebook_env[:inactive_concluded_lmgb_filters]).to be_falsey
end
it 'is true if the feature flag is on' do
@course.root_account.enable_feature! :inactive_concluded_lmgb_filters
get :show, params: {course_id: @course.id}
gradebook_env = assigns[:js_env][:GRADEBOOK_OPTIONS]
expect(gradebook_env[:inactive_concluded_lmgb_filters]).to be_truthy
end
end
end
end
end

View File

@ -341,6 +341,106 @@ describe OutcomeResultsController do
end
end
context 'with the inactive_concluded_lmgb_filters FF' do
context 'enabled' do
before do
@course.account.enable_feature!(:inactive_concluded_lmgb_filters)
end
it 'displays rollups for concluded enrollments when they are included' do
StudentEnrollment.find_by(user_id: @student2.id).conclude
json = parse_response(get_rollups({}))
rollups = json['rollups'].select{|r| r['links']['user'] == @student2.id.to_s}
expect(rollups.count).to eq(1)
expect(rollups.first['scores'][0]['score']).to eq 1.0
end
it 'does not display rollups for concluded enrollments when they are not included' do
StudentEnrollment.find_by(user_id: @student2.id).conclude
json = parse_response(get_rollups(exclude: 'concluded_enrollments'))
expect(json['rollups'].select{|r| r['links']['user'] == @student2.id.to_s}.count).to eq(0)
end
it 'displays rollups for a student who has an active and a concluded enrolllment regardless of filter' do
section1 = add_section 's1', course: outcome_course
student_in_section section1, user: @student2, allow_multiple_enrollments: true
StudentEnrollment.find_by(course_section_id: section1.id).conclude
json = parse_response(get_rollups(exclude: 'concluded_enrollments'))
rollups = json['rollups'].select{|r| r['links']['user'] == @student2.id.to_s}
expect(rollups.count).to eq(2)
expect(rollups.first['scores'][0]['score']).to eq 1.0
expect(rollups.second['scores'][0]['score']).to eq 1.0
end
it 'displays rollups for inactive enrollments when they are included' do
StudentEnrollment.find_by(user_id: @student2.id).deactivate
json = parse_response(get_rollups({}))
rollups = json['rollups'].select{|r| r['links']['user'] == @student2.id.to_s}
expect(rollups.count).to eq(1)
expect(rollups.first['scores'][0]['score']).to eq 1.0
end
it 'does not display rollups for inactive enrollments when they are not included' do
StudentEnrollment.find_by(user_id: @student2.id).deactivate
json = parse_response(get_rollups(exclude: 'inactive_enrollments'))
expect(json['rollups'].select{|r| r['links']['user'] == @student2.id.to_s}.count).to eq(0)
end
context 'users with enrollments of different enrollment states' do
before do
StudentEnrollment.find_by(user_id: @student2.id).deactivate
@section1 = add_section 's1', course: outcome_course
student_in_section @section1, user: @student2, allow_multiple_enrollments: true
StudentEnrollment.find_by(course_section_id: @section1.id).conclude
end
it 'users whose enrollments are all excluded are not included' do
json = parse_response(get_rollups(exclude: ['concluded_enrollments', 'inactive_enrollments']))
rollups = json['rollups'].select{|r| r['links']['user'] == @student2.id.to_s}
expect(rollups.count).to eq(0)
end
it 'users whose enrollments are all excluded are not included in a specified section' do
json = parse_response(get_rollups(exclude: ['concluded_enrollments', 'inactive_enrollments'],
section_id: @section1.id))
rollups = json['rollups'].select{|r| r['links']['user'] == @student2.id.to_s}
expect(rollups.count).to eq(0)
end
it 'users who contain an active enrollment are always included' do
section3 = add_section 's3', course: outcome_course
student_in_section section3, user: @student2, allow_multiple_enrollments: true
json = parse_response(get_rollups(exclude: ['concluded_enrollments', 'inactive_enrollments']))
rollups = json['rollups'].select{|r| r['links']['user'] == @student2.id.to_s}
expect(rollups.count).to eq(3)
expect(rollups.first['scores'][0]['score']).to eq 1.0
expect(rollups.second['scores'][0]['score']).to eq 1.0
expect(rollups.third['scores'][0]['score']).to eq 1.0
end
end
end
context 'disabled' do
before do
@course.account.disable_feature!(:inactive_concluded_lmgb_filters)
end
it 'does not display rollups for concluded enrollments when they are included' do
StudentEnrollment.find_by(user_id: @student2.id).conclude
json = parse_response(get_rollups({}))
rollups = json['rollups'].select{|r| r['links']['user'] == @student2.id.to_s}
expect(rollups.count).to eq(0)
end
it 'does not display for inactive enrollments when they are included' do
StudentEnrollment.find_by(user_id: @student2.id).deactivate
json = parse_response(get_rollups({}))
rollups = json['rollups'].select{|r| r['links']['user'] == @student2.id.to_s}
expect(rollups.count).to eq(0)
end
end
end
context 'sorting' do
it 'should validate sort_by parameter' do
get_rollups(sort_by: 'garbage')

View File

@ -273,7 +273,75 @@ describe "outcome gradebook" do
expect(means).to contain_exactly("2", "3")
end
context 'with learning mastery scales enabled' do
context 'with inactive_concluded_lmgb_filters enabled' do
before(:once) do
Account.default.set_feature_flag!('inactive_concluded_lmgb_filters', 'on')
end
it 'correctly displays inactive enrollments when the filter option is selected' do
StudentEnrollment.find_by(user_id: @student_1.id).deactivate
get "/courses/#{@course.id}/gradebook"
select_learning_mastery
wait_for_ajax_requests
active_students = [@student_2.name, @student_3.name]
student_names = ff('.outcome-student-cell-content').map {|cell| cell.text.split("\n")[0]}
expect(student_names.sort).to eq(active_students)
f('button[data-component="lmgb-student-filter-trigger"]').click
f('span[data-component="lmgb-student-filter-inactive-enrollments"]').click
wait_for_ajax_requests
active_students = [@student_1.name, @student_2.name, @student_3.name]
student_names = ff('.outcome-student-cell-content').map {|cell| cell.text.split("\n")[0]}
expect(student_names.sort).to eq(active_students)
end
it 'correctly displays concluded enrollments when the filter option is selected' do
StudentEnrollment.find_by(user_id: @student_1.id).conclude
get "/courses/#{@course.id}/gradebook"
select_learning_mastery
wait_for_ajax_requests
active_students = [@student_2.name, @student_3.name]
student_names = ff('.outcome-student-cell-content').map {|cell| cell.text.split("\n")[0]}
expect(student_names.sort).to eq(active_students)
f('button[data-component="lmgb-student-filter-trigger"]').click
f('span[data-component="lmgb-student-filter-concluded-enrollments"]').click
wait_for_ajax_requests
active_students = [@student_1.name, @student_2.name, @student_3.name]
student_names = ff('.outcome-student-cell-content').map {|cell| cell.text.split("\n")[0]}
expect(student_names.sort).to eq(active_students)
end
it 'correctly displays unassessed students when the filter option is selected' do
student_4 = User.create!(:name => 'Unassessed Student')
student_4.register!
@course.enroll_student(student_4)
get "/courses/#{@course.id}/gradebook"
select_learning_mastery
wait_for_ajax_requests
active_students = [@student_1.name, @student_2.name, @student_3.name]
student_names = ff('.outcome-student-cell-content').map {|cell| cell.text.split("\n")[0]}
expect(student_names.sort).to eq(active_students)
f('button[data-component="lmgb-student-filter-trigger"]').click
f('span[data-component="lmgb-student-filter-unassessed-students"]').click
wait_for_ajax_requests
active_students = [@student_1.name, @student_2.name, @student_3.name, student_4.name]
student_names = ff('.outcome-student-cell-content').map {|cell| cell.text.split("\n")[0]}
expect(student_names.sort).to eq(active_students.sort)
end
end
context 'with learning mastery scales enabled' do
before(:once) do
@rating1 = OutcomeProficiencyRating.new(description: 'best', points: 10, mastery: true, color: '00ff00')
@rating2 = OutcomeProficiencyRating.new(description: 'worst', points: 0, mastery: false, color: 'ff0000')