Add shortcuts for adding all members of a group or section to a conference

closes VICE-1880
flag=none

This change adds checkboxes for groups and sections to the members list
section of the dialog for creating / editing a conference. These
checkboxes are "shortcuts" that allow the user to quickly add all members
of a specific section or group to a conference. These shortcuts are
implemented client side and the  model for conferences is unchanged.

Test Plan:
- Make sure the conferences feature is enabled
- In a course open the dialog to create a new conference
- Uncheck the "Invite All" box
- Verify the following in the member list:
  - A checkbox for each section should appear in the "Sections"
    partition (only if there is more than one section in the course)
  - A checkbox for each group should appear in the "Groups" partition
    (only if there is at least one section in the course)
  - The headers for "Sections" and "Groups" should only appear if there
    is atleast item in the respective partition
  - The header for "Users" should only appear if one or more of the
    other partitions is present
  - Checking the box for section / group also checks the boxes for
    each of the users in that section / group
  - Unchecking the box for section / group also unchecks the boxes for
    each of the users in that section / group provided that another
    section / group that contains that user isn't also checked
  - If a user is added to the conference via a section or group you
    should not be able to uncheck the checkbox for that user.

Change-Id: I6896d9ac3066a4a66a118ec510e64e9b324cba8f
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/271824
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Product-Review: Katrina Hess <khess@instructure.com>
QA-Review: Chawn Neal <chawn.neal@instructure.com>
Reviewed-by: Chawn Neal <chawn.neal@instructure.com>
This commit is contained in:
Joseph Ditton 2020-11-09 13:16:58 -07:00 committed by Matthew Lemon
parent 94004d3208
commit 8a3c737b92
5 changed files with 411 additions and 7 deletions

View File

@ -278,6 +278,18 @@ class ConferencesController < ApplicationController
@render_alternatives = WebConference.conference_types(@context).all? { |ct| ct[:replace_with_alternatives] }
case @context
when Course
@sections = @context.course_sections
@groups = @context.active_groups
@group_user_ids_map = @groups.to_a.each_with_object({}) do |group, acc|
acc[group.id] = group.participating_users_in_context.map{|u| u.id.to_s}
acc
end
@section_user_ids_map = @sections.to_a.each_with_object({}) do |section, acc|
acc[section.id] = section.participants.map{|u| u.id.to_s}
acc
end
@users = User.where(:id => @context.current_enrollments.not_fake.active_by_date.where.not(:user_id => @current_user).select(:user_id)).
order(User.sortable_name_order_by_clause).to_a
@render_alternatives ||= @context.settings[:show_conference_alternatives].present?
@ -296,6 +308,10 @@ class ConferencesController < ApplicationController
default_conference: default_conference_json(@context, @current_user, session),
conference_type_details: conference_types_json(WebConference.conference_types(@context)),
users: @users.map { |u| {:id => u.id, :name => u.last_name_first} },
groups: @groups&.map { |g| {:id => g.id, :name => g.full_name} },
sections: @sections&.map { |s| {:id => s.id, :name => s.display_name} },
group_user_ids_map: @group_user_ids_map,
section_user_ids_map: @section_user_ids_map,
can_create_conferences: @context.grants_right?(@current_user, session, :create_conferences),
render_alternatives: @render_alternatives
)

View File

@ -119,6 +119,16 @@ describe ConferencesController do
expect(response).to be_successful
end
it "should include group and section data in the js_env" do
group(context: @course)
user_session(@teacher)
get 'index', params: {course_id: @course.id}
expect(assigns[:js_env][:groups]).to be_truthy
expect(assigns[:js_env][:sections]).to be_truthy
expect(assigns[:js_env][:group_user_ids_map]).to be_truthy
expect(assigns[:js_env][:section_user_ids_map]).to be_truthy
end
context "sets render_alternatives variable" do
it "should set to false by default" do
user_session(@teacher)

View File

@ -17,7 +17,7 @@
*/
import EditConferenceView from 'ui/features/conferences/backbone/views/EditConferenceView.coffee'
import Conference from 'ui/features/conferences/backbone/models/Conference.js'
import Conference from 'ui/features/conferences/backbone/models/Conference'
import tz from '@canvas/timezone'
import french from 'timezone/fr_FR'
import I18nStubber from 'helpers/I18nStubber'
@ -30,7 +30,21 @@ QUnit.module('EditConferenceView', {
this.datepickerSetting = {field: 'datepickerSetting', type: 'date_picker'}
fakeENV.setup({
conference_type_details: [{settings: [this.datepickerSetting]}],
users: [{id: 1, name: 'Owlswick Clamp'}]
users: [
{id: 1, name: 'Owlswick Clamp'},
{id: 2, name: 'Abby Zollinger'},
{id: 3, name: 'Bruce Young'}
],
sections: [
{id: 1, name: 'Section 1'},
{id: 2, name: 'Section 2'}
],
groups: [
{id: 1, name: 'Study Group 1'},
{id: 2, name: 'Study Group 2'}
],
section_user_ids_map: {1: [1, 2], 2: [3]},
group_user_ids_map: {1: [1], 2: [1, 2]}
})
},
teardown() {
@ -41,7 +55,7 @@ QUnit.module('EditConferenceView', {
}
})
test('updateConferenceUserSettingDetailsForConference localizes values for datepicker settings', function() {
test('updateConferenceUserSettingDetailsForConference localizes values for datepicker settings', function () {
tz.changeLocale(french, 'fr_FR', 'fr')
I18nStubber.pushFrame()
I18nStubber.setLocale('fr_FR')
@ -52,7 +66,7 @@ test('updateConferenceUserSettingDetailsForConference localizes values for datep
equal(this.datepickerSetting.value, 'ven. 7 août, 2015 17:00')
})
test('#show sets the proper title for new conferences', function() {
test('#show sets the proper title for new conferences', function () {
const expectedTitle = 'New Conference'
const attributes = {
recordings: [],
@ -70,7 +84,7 @@ test('#show sets the proper title for new conferences', function() {
equal(title, expectedTitle)
})
test('#show sets the proper title for editing conferences', function() {
test('#show sets the proper title for editing conferences', function () {
const expectedTitle = 'Edit &quot;InstructureCon&quot;'
const attributes = {
title: 'InstructureCon',
@ -89,7 +103,7 @@ test('#show sets the proper title for editing conferences', function() {
equal(title, expectedTitle)
})
test('#show sets localized durataion when editing conference', function() {
test('#show sets localized durataion when editing conference', function () {
const expectedDuration = '1,234.5'
const attributes = {
title: 'InstructureCon',
@ -109,7 +123,7 @@ test('#show sets localized durataion when editing conference', function() {
equal(duration, expectedDuration)
})
test('"remove observers" modifies "invite all course members"', function() {
test('"remove observers" modifies "invite all course members"', function () {
const attributes = {
title: 'Making Money',
recordings: [],
@ -129,3 +143,259 @@ test('"remove observers" modifies "invite all course members"', function() {
ok(this.view.$('#members_list').is(':visible'))
ok(this.view.$('#observers_remove').is(':disabled'))
})
test('sections should appear in member list if course has more than one section', function () {
const attributes = {
recordings: [],
user_settings: {
scheduled_date: new Date()
},
permissions: {
update: true
}
}
const conference = new Conference(attributes)
this.view.show(conference)
this.view.$('#user_all').click()
ok(this.view.$('#section_1').is(':visible'))
ok(this.view.$('#section_2').is(':visible'))
})
test('sections should not appear in member list if course has only section', function () {
const attributes = {
recordings: [],
user_settings: {
scheduled_date: new Date()
},
permissions: {
update: true
}
}
window.ENV.sections = [{name: 'Section 1', id: 1}]
const conference = new Conference(attributes)
this.view.show(conference)
this.view.$('#user_all').click()
ok(!this.view.$('#section_1').is(':visible'))
})
test('groups should appear in member list if course has one or more groups', function () {
const attributes = {
recordings: [],
user_settings: {
scheduled_date: new Date()
},
permissions: {
update: true
}
}
const conference = new Conference(attributes)
this.view.show(conference)
this.view.$('#user_all').click()
ok(this.view.$('#group_1').is(':visible'))
})
test('checking/unchecking a section also checks/unchecks the members that are in that section', function () {
const attributes = {
recordings: [],
user_settings: {
scheduled_date: new Date()
},
permissions: {
update: true
}
}
const conference = new Conference(attributes)
this.view.show(conference)
this.view.$('#user_all').click()
this.view.$('#section_2').click()
ok(!this.view.$('#user_1').is(':checked'))
this.view.$('#section_1').click()
ok(this.view.$('#user_1').is(':checked'))
this.view.$('#section_1').click()
ok(!this.view.$('#user_1').is(':checked'))
})
test('checking/unchecking a groups also checks/unchecks the members that are in that group', function () {
const attributes = {
recordings: [],
user_settings: {
scheduled_date: new Date()
},
permissions: {
update: true
}
}
const conference = new Conference(attributes)
this.view.show(conference)
this.view.$('#user_all').click()
ok(!this.view.$('#user_1').is(':checked'))
this.view.$('#group_1').click()
ok(this.view.$('#user_1').is(':checked'))
this.view.$('#group_1').click()
ok(!this.view.$('#user_1').is(':checked'))
})
test('unchecking a group only unchecks members that have not been selected by section also', function () {
const attributes = {
recordings: [],
user_settings: {
scheduled_date: new Date()
},
permissions: {
update: true
}
}
const conference = new Conference(attributes)
this.view.show(conference)
this.view.$('#user_all').click()
ok(!this.view.$('#user_1').is(':checked'))
this.view.$('#group_1').click()
this.view.$('#section_1').click()
ok(this.view.$('#user_1').is(':checked'))
ok(this.view.$('#user_2').is(':checked'))
this.view.$('#group_1').click()
ok(this.view.$('#user_1').is(':checked'))
})
test('unchecking a section only unchecks members that have not been selected by group also', function () {
const attributes = {
recordings: [],
user_settings: {
scheduled_date: new Date()
},
permissions: {
update: true
}
}
const conference = new Conference(attributes)
this.view.show(conference)
this.view.$('#user_all').click()
ok(!this.view.$('#user_1').is(':checked'))
this.view.$('#group_1').click()
this.view.$('#section_1').click()
ok(this.view.$('#user_1').is(':checked'))
ok(this.view.$('#user_2').is(':checked'))
this.view.$('#section_1').click()
ok(this.view.$('#user_1').is(':checked'))
ok(!this.view.$('#user_2').is(':checked'))
})
test('While editing a conference the box for a group should be checked and disabled if everyone in the group is a participant', function () {
const attributes = {
title: 'Making Money',
recordings: [],
user_settings: {
scheduled_date: new Date()
},
permissions: {
update: true
},
user_ids: [1]
}
const conference = new Conference(attributes)
this.view.show(conference, {isEditing: true})
ok(this.view.$('#group_1').is(':checked'))
ok(this.view.$('#group_1').is(':disabled'))
})
test('While editing a conference the box for a section should be checked and disabled if everyone in the section is a participant', function () {
const attributes = {
title: 'Making Money',
recordings: [],
user_settings: {
scheduled_date: new Date()
},
permissions: {
update: true
},
user_ids: [3]
}
const conference = new Conference(attributes)
this.view.show(conference, {isEditing: true})
ok(this.view.$('#section_2').is(':checked'))
ok(this.view.$('#section_2').is(':disabled'))
})
test('While editing a conference unchecking a group should only uncheck members who are not a part of the existing conference', function () {
const attributes = {
title: 'Making Money',
recordings: [],
user_settings: {
scheduled_date: new Date()
},
permissions: {
update: true
},
user_ids: [1]
}
const conference = new Conference(attributes)
this.view.show(conference, {isEditing: true})
ok(!this.view.$('#group_2').is(':checked'))
ok(!this.view.$('#user_2').is(':checked'))
this.view.$('#group_2').click()
ok(this.view.$('#user_1').is(':checked'))
ok(this.view.$('#user_2').is(':checked'))
this.view.$('#group_2').click()
ok(this.view.$('#user_1').is(':checked'))
ok(!this.view.$('#user_2').is(':checked'))
})
test('While editing a conference unchecking a section should only uncheck member who are not a part of the existing conference', function () {
const attributes = {
title: 'Making Money',
recordings: [],
user_settings: {
scheduled_date: new Date()
},
permissions: {
update: true
},
user_ids: [1]
}
const conference = new Conference(attributes)
this.view.show(conference, {isEditing: true})
ok(!this.view.$('#section_1').is(':checked'))
ok(!this.view.$('#user_2').is(':checked'))
this.view.$('#section_1').click()
ok(this.view.$('#user_1').is(':checked'))
ok(this.view.$('#user_2').is(':checked'))
this.view.$('#section_1').click()
ok(this.view.$('#user_1').is(':checked'))
ok(!this.view.$('#user_2').is(':checked'))
})
test('while context_is_group = true no sections or groups should appear in the member list', function () {
const attributes = {
title: 'Making Money',
recordings: [],
user_settings: {
scheduled_date: new Date()
},
permissions: {
update: true
}
}
window.ENV.context_is_group = true
const conference = new Conference(attributes)
this.view.show(conference)
ok(!this.view.$('#section_1').is(':visible'))
ok(!this.view.$('#section_2').is(':visible'))
ok(!this.view.$('#group_1').is(':visible'))
ok(!this.view.$('#group_2').is(':visible'))
})

View File

@ -45,7 +45,9 @@ export default class EditConferenceView extends DialogBaseView
@delegateEvents()
@toggleAllUsers()
@markInvitedUsers()
@markInvitedSectionsAndGroups()
@renderConferenceFormUserSettings()
@setupGroupAndSectionEventListeners()
@$('form').formSubmit(
object_name: 'web_conference'
beforeSubmit: (data) =>
@ -105,14 +107,23 @@ export default class EditConferenceView extends DialogBaseView
if numberHelper.validate(conferenceData.duration)
conferenceData.duration = I18n.n(conferenceData.duration)
hide_groups = !ENV.groups || ENV.groups.length == 0
hide_sections = !ENV.sections || ENV.sections.length <= 1
hide_user_header = hide_groups && hide_sections
json =
settings:
is_editing: is_editing
is_adding: is_adding
disable_duration_changes: ((conferenceData['long_running'] || is_editing) && conferenceData['started_at'])
auth_token: authenticity_token()
hide_sections: hide_sections
hide_groups: hide_groups
hide_user_header: hide_user_header
conferenceData: conferenceData
users: ENV.users
sections: ENV.sections
groups: ENV.groups
context_is_group: ENV.context_asset_string.split("_")[0] == "group"
conferenceTypes: ENV.conference_type_details.map((type) ->
{name: type.name, type: type.type, selected: (conferenceData.conference_type == type.type)}
@ -203,9 +214,71 @@ export default class EditConferenceView extends DialogBaseView
el.attr('disabled', true)
)
markInvitedSectionsAndGroups: ->
_.each(ENV.sections, (section) =>
section_user_ids = ENV.section_user_ids_map[section.id]
intersection = _.intersection(section_user_ids, @model.get("user_ids"))
if (intersection.length == section_user_ids.length)
el = $("#members_list .member.section_" + section.id).find(":checkbox")
el.attr('checked', true)
el.attr('disabled', true)
)
_.each(ENV.groups, (group) =>
group_user_ids = ENV.group_user_ids_map[group.id]
intersection = _.intersection(group_user_ids, @model.get("user_ids"))
if (intersection.length == group_user_ids.length)
el = $("#members_list .member.group_" + group.id).find(":checkbox")
el.attr('checked', true)
el.attr('disabled', true)
)
changeLongRunning: (e) ->
if ($(e.currentTarget).is(':checked'))
$('#web_conference_duration').prop('disabled', true).val('')
else
# use restore time from data attribute
$('#web_conference_duration').prop('disabled', false).val($('#web_conference_duration').data('restore-value'))
setupGroupAndSectionEventListeners: () ->
selectedBySection = []
selectedByGroup = []
toggleMember = (id, checked) ->
memberEl = $("#members_list .member.user_" + id).find(":checkbox")
memberEl.attr('checked', checked)
memberEl.attr('disabled', checked)
_.each(ENV.groups, (group) =>
el = $("#members_list .member.group_" + group.id)
el.on("change", (e) =>
_.each(
ENV.group_user_ids_map[group.id],
(id) =>
if (e.target.checked)
selectedByGroup.push(id)
toggleMember(id, e.target.checked)
else
selectedByGroup = _.without(selectedByGroup, id)
if (!_.contains(selectedBySection, id) && !_.contains(@model.get("user_ids"), id))
toggleMember(id, e.target.checked)
)
)
)
_.each(ENV.sections, (section) =>
el = $("#members_list .member.section_" + section.id)
el.on("change", (e) =>
_.each(
ENV.section_user_ids_map[section.id],
(id) =>
if (e.target.checked)
selectedBySection.push(id)
toggleMember(id, e.target.checked)
else
selectedBySection = _.without(selectedBySection, id)
if (!_.contains(selectedByGroup, id) && !_.contains(@model.get("user_ids"), id))
toggleMember(id, e.target.checked)
)
)
)

View File

@ -74,6 +74,41 @@
{{/unless}}
<div>
<ul id="members_list" style="border: 1px solid #333; padding: 10px; overflow-y: auto; max-height: 150px;">
{{#unless context_is_group}}
{{#unless settings.hide_sections}}
<li>
<strong>{{#t}}Sections{{/t}}</strong>
</li>
{{# each sections }}
<li class="member section_{{id}}">
<label class="checkbox" for="section_{{id}}">
<input name="section[{{id}}]" type="hidden" value="0">
<input id="section_{{id}}" name="section[{{id}}]" type="checkbox" value="1">
{{name}}
</label>
</li>
{{/each}}
{{/unless}}
{{#unless settings.hide_groups}}
<li>
<strong>{{#t}}Groups{{/t}}</strong>
</li>
{{# each groups }}
<li class="member group_{{id}}">
<label class="checkbox" for="group_{{id}}">
<input name="group[{{id}}]" type="hidden" value="0">
<input id="group_{{id}}" name="group[{{id}}]" type="checkbox" value="1">
{{name}}
</label>
</li>
{{/each}}
{{/unless}}
{{#unless settings.hide_user_header}}
<li>
<strong>{{#t}}Users{{/t}}</strong>
</li>
{{/unless}}
{{/unless}}
{{# each users}}
<li class="member user_{{id}}">
<label class="checkbox" for="user_{{id}}">