improve student group signup experience

fixes CNVS-11832
fixes CNVS-11836
fixes CNVS-13327

test plan

  - enable feature student_groups_next for course
  - regression test groups page student view

  - regression test instructor groups page

  - as an instructor have multiple groups in a group set
  - visit one of the groups
  - the instructor can switch between the groups in the group set

  - as a student you should be able to create a group

Change-Id: Iacf1eaf4467b57f307d45245b53dfc93f14154ff
Reviewed-on: https://gerrit.instructure.com/34454
Tested-by: Jenkins <jenkins@instructure.com>
Reviewed-by: Ethan Vizitei <evizitei@instructure.com>
QA-Review: Trevor deHaan <tdehaan@instructure.com>
Product-Review: Drew Bowman <dbowman@instructure.com>
This commit is contained in:
Drew Bowman 2014-05-19 16:20:17 -04:00
parent 923040f873
commit 3c1786adac
50 changed files with 1225 additions and 126 deletions

View File

@ -10,6 +10,12 @@ define [
return if $trigger.data('kyleMenu')
opts = $.extend {noButton: true}, $trigger.data('kyleMenuOptions')
opts.appendMenuTo = 'body' if $trigger.data('append-to-body')
opts = $.extend opts,
popupOpts:
position:
my: $trigger.data('popup-my')
at: $trigger.data('popup-at')
within: $trigger.data('popup-within')
new KyleMenu($trigger, opts)
$trigger.trigger(event)

View File

@ -26,13 +26,15 @@ require [
'compiled/collections/RosterUserCollection'
'compiled/collections/RolesCollection'
'compiled/collections/SectionCollection'
'compiled/collections/GroupCategoryCollection'
'compiled/views/InputFilterView'
'compiled/views/PaginatedCollectionView'
'compiled/views/courses/roster/RosterUserView'
'compiled/views/courses/roster/RosterView'
'compiled/views/courses/roster/RosterTabsView'
'compiled/views/courses/roster/ResendInvitationsView'
'jquery'
], (I18n, {Model}, CreateUserList, Role, CreateUsersView, RoleSelectView, rosterUsersTemplate, RosterUserCollection, RolesCollection, SectionCollection, InputFilterView, PaginatedCollectionView, RosterUserView, RosterView, ResendInvitationsView, $) ->
], (I18n, {Model}, CreateUserList, Role, CreateUsersView, RoleSelectView, rosterUsersTemplate, RosterUserCollection, RolesCollection, SectionCollection, GroupCategoryCollection, InputFilterView, PaginatedCollectionView, RosterUserView, RosterView, RosterTabsView, ResendInvitationsView, $) ->
fetchOptions =
include: ['avatar_url', 'enrollments', 'email', 'observed_users']
@ -69,8 +71,16 @@ require [
model: course
resendInvitationsUrl: ENV.resend_invitations_url
canResend: ENV.permissions.manage_students or ENV.permissions.manage_admin_users
groupCategories = new (GroupCategoryCollection.extend({url: "/api/v1/courses/#{ENV.course?.id}/group_categories"}))
rosterTabsView = new RosterTabsView
collection: groupCategories
rosterTabsView.fetch()
@app = new RosterView
usersView: usersView
rosterTabsView: rosterTabsView
inputFilterView: inputFilterView
roleSelectView: roleSelectView
createUsersView: createUsersView
@ -79,7 +89,7 @@ require [
roles: ENV.ALL_ROLES
permissions: ENV.permissions
course: ENV.course
users.once 'reset', ->
users.on 'reset', ->
numUsers = users.length
@ -90,7 +100,7 @@ require [
else
msg = I18n.t "filter_multiple_users_found", "%{userCount} users found.", userCount: numUsers
$('#aria_alerts').empty().text msg
@app.render()
@app.$el.appendTo $('#content')

View File

@ -1,6 +1,7 @@
define [
'ember'
'./dialog_mixin'
'../templates/components/form-dialog'
], (Em, DialogMixin) ->
FormDialogComponent = Em.Component.extend DialogMixin

View File

@ -6,12 +6,12 @@ define [
'../../shared/components/ic_actions_component'
'../../shared/components/ic_publish_icon_component'
'./date_transform'
], (Ember, env, Util, FastSelectComponent) ->
], (Ember, env, Util, FastSelectComponent, ConfirmDialogComponent, FormDialogComponent) ->
Ember.Util = Util
Ember.onLoad 'Ember.Application', (Application) ->
Application.initializer
name: 'FastSelectComponent'
name: 'SharedComponents'
initialize: (container, application) ->
container.register 'component:fast-select', FastSelectComponent
Application.initializer

View File

@ -1,4 +1,4 @@
<form class="form-dialog">
<form class="form-dialog" {{bind-attr id=id}}>
<div class="form-dialog-content">
{{yield}}
</div>

View File

@ -0,0 +1,38 @@
define [
'ember'
'./dialog_mixin'
'i18n!confirm_dialog_component'
], (Em, DialogMixin, I18n) ->
###
# All parameters except for title are optional.
#
# title: I18n'd title that is displayed as the dialog title.
#
# Defaults:
#
# on-submit: Sends 'submit' action.
# on-cancel: Sends 'cancel' action.
# cancel-text: I18n'd version of the word "Cancel"
# confirm-text: I18n'd version of the word "Ok"
#
# Usage:
#
# {{#confirm-dialog
# on-submit="myConfirmAction"
# on-cancel="myCancelAction"
# cancel-text=somePropertyWithI18nForCancellingTheAction
# title=myI18ndTitleProperty
# }}
#
# {{#t "confirm_deletion_of_quiz"}}
# Are you sure you want to delete this quiz?
# {{/t}}
#
# {{/confirm-dialog}}
#
###
ConfirmDialogComponent = Em.Component.extend DialogMixin,
'on-cancel': 'cancel'
'confirm-text': I18n.t('ok', 'Ok')

View File

@ -0,0 +1,111 @@
define [
'ember'
'jquery'
'i18n!confirm_dialog'
'jqueryui/dialog'
'compiled/jquery/fixDialogButtons'
], (Ember, $, I18n) ->
CONFIRM_BTN = '.confirm-dialog-confirm-btn'
CANCEL_BTN = '.confirm-dialog-cancel-btn'
DialogMixin = Ember.Mixin.create
# header element at the top of the page
# Need to know this so we can decrease its z-index
# so the overlay appears as correctly as possible
headerElement: '#header'
height: '500'
width: '550'
position:
my: 'center'
at: 'center'
of: window
'fix-dialog-buttons': true
'confirm-text': I18n.t('submit', 'Submit')
'cancel-text': I18n.t('cancel', 'Cancel')
'on-submit': 'submit'
# Default Destroy Action
'_destroyAction': '_destroyModal'
turnIntoDialog: (->
unless @get('title')
throw new Em.Error "You must provide a title to a Dialog Component!"
$el = @$()
$el.dialog
autoOpen: false
title: @get('title')
modal: true
height: @get('height')
width: @get('width')
fixDialogButtons: @get('fix-dialog-buttons')
position: @get('position')
close: => @sendAction('_destroyAction')
uiDialog = $el
.dialog('open')
.data('dialog')
.uiDialog
@_moveWithinEmberAppScope($el, uiDialog)
uiDialog.on 'keypress', (event) =>
Em.run this, 'keyPress', event
uiDialog.on 'click', CONFIRM_BTN, (event) =>
Em.run this, 'closeAndConfirm'
uiDialog.on 'click', CANCEL_BTN, (event) =>
Em.run this, 'closeAndCancel'
).on 'didInsertElement'
_close: ->
@$().dialog 'close'
# Need to move the element within the Ember app root element,
# otherwise, things like {{action}} won't be handled by Ember.
_moveWithinEmberAppScope: ($el, uiDialog) ->
$overlay = $el.dialog().data('dialog').overlay.$el
$overlay.css('position', 'fixed')
rootElement = Em.get('App.rootElement' || '#content')
$content = $ rootElement
uiDialog.appendTo $content
$overlay.appendTo $content
uiDialog.position(@get('position'))
uiDialog.focus()
# Bring the "Courses", "Assignments", etc menu down in z-index
# so the overlay doesn't get hidden by it.
$('#header').css('z-index', '0')
closeAndConfirm: ->
@_close()
@sendAction 'on-submit'
false
closeAndCancel: ->
@_close()
@sendAction 'on-cancel'
false
destroyDialog: (->
Ember.$('#header').css 'z-index', '11'
@$().data('dialog').uiDialog.off()
@$().dialog 'destroy'
).on 'willDestroyElement'
# Send the appropriate event when the enter key is pressed on the confirm
# or cancel buttons.
#
# Will not block event propagation if the event target is not a button.
keyPress: (event) ->
return true unless event.keyCode is $.ui.keyCode.ENTER
$target = $ event.target
return true unless $target.is("button")
if $target.hasClass CONFIRM_BTN.replace('.', '')
@closeAndConfirm()
else
@closeAndCancel()
false

View File

@ -0,0 +1,7 @@
define [
'ember'
'./dialog_mixin'
'../templates/components/form-dialog'
], (Em, DialogMixin) ->
FormDialogComponent = Em.Component.extend DialogMixin

View File

@ -0,0 +1,15 @@
<form class="form-dialog">
<div class="form-dialog-content">
{{yield}}
</div>
<div class="form-controls">
<button class="btn confirm-dialog-cancel-btn" {{action 'cancel'}}>
{{cancel-text}}
</button>
<button class="btn btn-primary confirm-dialog-confirm-btn" {{action 'confirm'}}>
{{confirm-text}}
</button>
</div>
</form>

View File

@ -0,0 +1,15 @@
<form class="form-dialog" {{bind-attr id=id}}>
<div class="form-dialog-content">
{{yield}}
</div>
<div class="form-controls">
<button class="btn confirm-dialog-cancel-btn" {{action 'cancel'}}>
{{cancel-text}}
</button>
<button class="btn btn-primary confirm-dialog-confirm-btn" {{action 'confirm'}} {{bind-attr disabled=submit-disabled}}>
{{confirm-text}}
</button>
</div>
</form>

View File

@ -0,0 +1,22 @@
define [
'ember'
'ember-data'
'ic-ajax'
], (Ember, DS,ajax) ->
GroupAdapter = DS.RESTAdapter.extend
namespace: 'api/v1'
urlFor: (record) ->
if record.get('course_id')
"/courses/#{record.get('course_id')}/groups"
else
"/#{@namespace}/groups"
createRecord: (store, type, record) ->
url = @urlFor(record)
delete record.course_id
invites = record.invites
delete record.invites
@ajax(url, "POST", { data: {group: record, invitees: invites }})

View File

@ -0,0 +1,15 @@
define [
'ember'
'ember-data'
'ic-ajax'
], (Ember, DS,ajax) ->
MembershipAdapter = DS.RESTAdapter.extend
namespace: 'api/v1'
urlFor: (record) ->
"/#{@namespace}/groups/#{record.get('group_id')}/memberships"
createRecord: (store, type, record) ->
@ajax(@urlFor(record), "POST", { data: { user_id: record.get('user_id') }})

View File

@ -0,0 +1,17 @@
define [
'ember'
'../../shared/components/ic_lazy_list_component'
'../../shared/components/form_dialog_component'
], (Ember, ICLazyList, FormDialogComponent) ->
Ember.onLoad 'Ember.Application', (Application) ->
Application.initializer
name: 'SharedComponents'
initialize: (container, application) ->
container.register 'component:form-dialog', FormDialogComponent
container.register 'component:ic-lazy-list', ICLazyList
Ember.Application.extend
rootElement: '#content'

View File

@ -0,0 +1,4 @@
define ->
route = ->
@resource "student_groups", path: '/'

View File

@ -0,0 +1,76 @@
define [
'i18n!student_groups'
'ember'
'ic-ajax'
], (I18n, Ember, ajax) ->
GroupController = Ember.ObjectController.extend
currentUser: {}
groupUrl: (->
"/groups/#{@get('id')}"
).property('id')
usersUrl: (->
"/api/v1/groups/#{@get('id')}/memberships"
).property('id')
i18nStudentsCount: (->
I18n.t('students_count', 'student', count: @get('memberCount'))
).property('memberCount')
showWhileSearching: (->
if @get('parentController.filterText').length > 0
@set('showBody', true)
else
@set('showBody', false)
).observes('parentController.filterText').on('init')
memberCount: Ember.computed.alias('users.length')
hasMultipleMembers: Ember.computed.not('memberCount',1)
actions:
visitGroup: ->
if @get('isMember')
window.location.href = @get('groupUrl')
toggleBody: ->
if @.get('memberCount') > 0
@toggleProperty('showBody')
else
@set('showBody', false)
join: (group) ->
membership = @store.createRecord('membership', {
user_id: ENV.current_user_id
group_id: @get('id')
})
controller = this
membership.save().then (membership)->
controller.parentController.removeFromCategory(controller.get('group_category_id'))
controller.get('users').addObject(ENV.current_user)
controller.parentController.set('noticeMessage', "Joined Group")
leave: (group) ->
controller = this
Ember.run =>
ajax("#{@get('usersUrl')}/self",{type: "DELETE"}).then (response) ->
user = controller.get('users').findBy('id', ENV.current_user_id)
controller.get('users').removeObject(user)
controller.parentController.set('noticeMessage', "Left Group")
if controller.get('memberCount') == 0
controller.set('showBody', false)
isMember: (->
@get('model').users.findBy('id', ENV.current_user_id)?
).property('users.@each.id')
canSignup: (->
@get('group_category.self_signup') == "enabled"
).property('group_category.self_signup')
isFull: (->
@get('max_membership')? and @get('memberCount') >= @get('max_membership')
).property('memberCount')
isMemberOfCategory: (->
@parentController.isMemberOfCategory(@get('group_category_id'))
).property('users.@each.id')

View File

@ -0,0 +1,46 @@
define [
'ember'
'ic-ajax'
], (Ember,ajax) ->
NewGroupController = Ember.ObjectController.extend
needs: [ 'student_groups', 'users' ]
joinLevel: 'parent_context_auto_join'
name: ''
nameError: (->
length = @get('name').length
length == 0 or length > 255
).property('name.length')
selectOptions: [
{value: 'parent_context_auto_join', desc: 'Course members are free to join'},
{value: 'invitation_only', desc: 'Membership by invitation only'},
]
buildInvites: ->
invites = {}
@get('controllers.users').forEach (user) ->
invites[user.get('id')] = if user.get('invite') then 1 else 0
invites
actions:
createGroup: ->
unless @nameError
invites = @buildInvites()
group = @store.createRecord 'group',
name: @get('name')
join_level: @get('joinLevel')
course_id: ENV.course_id
invites: invites
group.save().then (group) =>
@set('name','')
json = group.toJSON()
json.users = [ENV.current_user]
@get('controllers.student_groups.groups').pushObject(json)

View File

@ -0,0 +1,44 @@
define [
'i18n!student_groups'
'ember'
], (I18n,Ember) ->
StudentGroupsController = Ember.ObjectController.extend
filterText: ""
content: Ember.Object.create()
searchPlaceholder: (->
I18n.t('search_groups_placeholder',"Search Groups or People")
).property()
searchAriaLabel: (->
I18n.t('student_groups_filter_description',"As you type in this field, the list of groups will be automatically filtered to only include those whose names match your input.")
).property()
usersPath: "/courses/#{ENV.course_id}/users"
groupsUrl: "/api/v1/courses/#{ENV.course_id}/groups?include[]=users&include[]=group_category"
sortedGroups: (->
groups = @get('groups') || []
text = @get('filterText').toLowerCase()
groups = groups.filter (group) ->
text.length == 0 or
group.name.toLowerCase().indexOf(text) >= 0 or
group.users.find (user) ->
(user.display_name and user.display_name.toLowerCase().indexOf(text) >= 0) or
(user.name and user.name.toLowerCase().indexOf(text) >= 0)
groups.sortBy('group_category_id')
).property('groups.[]', 'filterText')
removeFromCategory: (categoryId) ->
@groupsForCategory(categoryId).forEach (group) ->
user = group.users.findBy('id', ENV.current_user_id)
if user
group.users.removeObject(user)
groupsForCategory: (categoryId) ->
@get('groups').filterBy('group_category_id', categoryId)
isMemberOfCategory: (categoryId) ->
@groupsForCategory(categoryId).any (group) ->
group.users.findBy('id', ENV.current_user_id)

View File

@ -0,0 +1,17 @@
define [
'i18n!student_groups'
'ember'
'ic-ajax'
], (I18n, Ember, ajax) ->
UserController = Ember.ObjectController.extend
idString:(->
"invitees_#{@get('id')}"
).property('id')
isCurrentUser: (->
ENV.current_user_id == @get('id')
).property('id')
invite:false

View File

@ -0,0 +1,5 @@
define ['ember'], (Ember) ->
UsersController = Ember.ArrayController.extend
usersPath: "/api/v1/courses/#{ENV.course_id}/users"
itemController: 'user'

View File

@ -0,0 +1,19 @@
define [
'ember'
'ember-data'
], (Ember,DS) ->
attr = DS.attr
Group = DS.Model.extend
name: attr()
join_level: attr()
users: attr()
Group.reopenClass
url: ->
debugger
"/api/v1"

View File

@ -0,0 +1,7 @@
define [
'ember'
'ember-data'
], (Ember, DS) ->
Membership = DS.Model.extend
user_id: DS.attr('number')
group_id: DS.attr('number')

View File

@ -0,0 +1,16 @@
define ['ember'], (Ember) ->
StudentGroupsRoute = Ember.Route.extend
actions:
# TODO: Create a DialogRoute that has this action.
_destroyModal: ->
@disconnectOutlet
outlet: 'modal'
parentView: 'application'
newGroup: ->
@render 'new_group',
into: 'application'
outlet: 'modal'
theParent: @controller

View File

@ -0,0 +1,24 @@
define [
'ember-data'
], (DS) ->
ApplicationSerializer = DS.ActiveModelSerializer.extend
# A lot of CanvasAPI responses look like:
# {
# "id": "1",
# "attribute": "value"
# }
#
# Turn them into:
#
# {
# "pluralized_resource_name": [{
# "id": "1",
# "attribute": "value"
# }]
# }
extractSingle: (store, type, payload, id, requestType) ->
obj = {}
obj[type.typeKey.pluralize()] = payload
this._super store, type, obj, id, requestType

View File

@ -0,0 +1,2 @@
{{outlet}}
{{outlet modal}}

View File

@ -0,0 +1,63 @@
{{#form-dialog
id="add_group_form"
title="New Student Group"
on-submit="createGroup"
height=500
width=700
fix-dialog-buttons=false
submit-disabled=nameError
}}
<p>
{{#t 'group_explanation'}}
Groups are a good place to collaborate on projects or to figure out schedules for study sessions
and the like. Every group gets a calendar, a wiki, discussions, and a little bit of space to store
files. Groups can collaborate on documents, or even schedule web conferences.
It's really like a mini-course where you can work with a smaller number of students on a
more focused project.
{{/t}}
</p>
<table class="formtable">
<tr>
<td><label for="group_name">{{#t 'name'}}Group Name{{/t}}</label></td>
<td>
{{view Ember.TextField id="group_name" value=name onEvent="keyPress" type="search" action="validate"}}
{{#if nameError}}
<span class="text-error">
{{#t 'name_error'}}Group name is required{{/t}}
</span>
{{/if}}
</td>
</tr>
<tr>
<td><label for="">{{#t 'joining'}}Joining{{/t}}</label></td>
<td>
{{view Ember.Select
id="group_join_level"
content=selectOptions
value=joinLevel
optionLabelPath="content.desc"
optionValuePath="content.value"
aria-label="Group Join Options"
}}
</td>
</tr>
<tr>
<td>
<label for="">Invite</label>
</td>
<td>
<ul class="unstyled_list">
{{#view controller=controllers.users}}
{{#ic-lazy-list data=content href=usersPath}}
{{#each }}
{{#unless isCurrentUser}}
<li><label class="checkbox">{{input type="checkbox" checked=invite id=idString}}{{name}}</label></li>
{{/unless}}
{{/each}}
{{/ic-lazy-list}}
{{/view}}
</ul>
</td>
</tr>
</table>
{{/form-dialog}}

View File

@ -0,0 +1,92 @@
{{#if noticeMessage}}
<p>
<div class="alert alert-success">
{{noticeMessage}}
</div>
</p>
{{/if}}
<div class='pull-right group-categories-actions'>
<button class="btn btn-primary add_group_link" aria-controls="testdialog" {{action 'newGroup'}} >
<i class='icon-plus' aria-label="new"></i> {{#t 'group'}}Group{{/t}}
</button>
</div>
<div id="group_categories_tabs" class="ui-tabs-minimal ui-tabs ui-widget ui-widget-content ui-corner-all">
<ul class='collectionViewItems ui-tabs-nav ui-helper-reset ui-helper-clearfix ui-widget-header ui-corner-all'>
<li class="ui-state-default ui-corner-top"><a {{bind-attr href=usersPath}}>{{#t 'student_groups_tabs_everyone'}}Everyone{{/t}}</a></li>
<li class="ui-state-default ui-corner-top ui-tabs-active ui-state-active"><a href="#">{{#t 'student_groups_tabs_groups'}}Groups{{/t}}</a></li>
</ul>
<div class="roster-tab tab-panel">
<div class="form-inline clearfix content-box">
<label for="search_field" class="screenreader-only">{{searchAriaLabel}}</label>
{{view Ember.TextField
value=filterText
id="search_field"
action="filter"
type="search"
onEvent="keyPress"
placeholder=searchPlaceholder
aria-label=searchAriaLabel
}}
</div>
{{#ic-lazy-list data=groups href=groupsUrl}}
{{#each sortedGroups itemController="group" }}
<div {{bind-attr class=":accordion :student-groups :content-box showBody isMember"}}>
<div class="student-group-header clearfix" >
<i class="icon-mini-arrow-right" aria-hidden="true"></i>
<i class="icon-mini-arrow-down" aria-hidden="true"></i>
<div class="student-group-title">
<h3>
<a {{action 'toggleBody'}} href="#" {{bind-attr aria-expanded=showBody}} aria-controls="student-group-body" aria-label="show students for group">{{name}}</a>
<small>{{group_category.name}}</small>
</h3>
{{#if isMember}}
<a {{action 'visitGroup'}} href="#" aria-label="visit group page">{{#t 'visit'}}Visit{{/t}}</a>
{{/if}}
</div>
{{#if isMember}}
{{#if canSignup}}
<a href="#" class="student-group-join" {{action "leave" this}} aria-label="leave group">{{#t 'student_groups_leave'}}Leave{{/t}}</a>
{{else}}
<span class="student-group-join">&nbsp</span>
{{/if}}
{{else}}
{{#if canSignup}}
{{#if isFull}}
<span class="student-group-join" aria-hidden="true">{{#t 'full'}}Full{{/t}}</span>
<span class="screenreader-only">{{#t 'group_full'}}Group is full{{/t}}</span>
{{else}}
{{#if isMemberOfCategory}}
<a href="#" class="student-group-join" {{action "join" this}} aria-label="switch to group">{{#t 'student_groups_switch'}}Switch To{{/t}}</a>
{{else}}
<a href="#" class="student-group-join" {{action "join" this}} aria-label="join group">{{#t 'student_groups_join'}}Join{{/t}}</a>
{{/if}}
{{/if}}
{{else}}
{{#if isFull}}
<span class="student-group-join" aria-label="group is full">{{#t 'full'}}Full{{/t}}</span>
{{else}}
<span class="student-group-join" aria-label="group is invite only">{{#t 'invite_only'}}Invite Only{{/t}}</span>
{{/if}}
{{/if}}
{{/if}}
<span class="student-group-students">
{{i18nStudentsCount}}
</span>
</div>
{{#if showBody}}
<div class="student-group-body">
<ul class="student-group-list clearfix" aria-label="group members">
{{#each users}}
<li>
{{#if display_name}}{{display_name}}{{else}}{{short_name}}{{/if}}
</li>
{{/each}}
</ul>
</div>
{{/if}}
</div>
{{/each}}
{{/ic-lazy-list}}
</div>
</div>

View File

@ -0,0 +1,19 @@
define [
'./start_app'
'ember'
'ic-ajax'
], (startApp, Ember, ajax) ->
App = null
module 'student_groups',
setup: ->
App = startApp()
teardown: ->
Ember.run App, 'destroy'
test 'Ember is running', ->
ok(true)

View File

@ -0,0 +1,60 @@
define [
'../start_app'
'ember'
'ic-ajax'
'../../controllers/group_controller'
'../../controllers/student_groups_controller'
'helpers/fakeENV'
], (startApp, Ember, ajax, GroupController, StudentGroupsController, fakeENV) ->
App = null
{run} = Ember
module 'group_controller',
setup: ->
fakeENV.setup()
App = startApp()
run => @sgc = StudentGroupsController.create()
run => @gc = GroupController.create(parentController: @sgc, showBody: false)
run =>
@group =
id: 1
name: "one"
group_category_id: 2
users: [{id: 1, name: "steve"}, {id: 2, name: "cliff"}, {id: 3, name: "walt"}, {id: 4, name: "pinkman"}]
@gc.set('content', @group)
teardown: ->
run =>
fakeENV.teardown()
@gc.destroy()
@sgc.destroy()
Ember.run App, 'destroy'
test 'should show body while searching', ->
equal @gc.get('showBody'), false
@sgc.set('filterText', "abc")
equal @gc.get('showBody'), true
test 'member count should sum the users', ->
equal @gc.get('memberCount'), @group.users.length
test 'toggleBody should change show body if the group has members', ->
@gc.send('toggleBody')
equal @gc.get('showBody'), true
test 'leave should remove the current user from the group', ->
ENV.current_user_id = 1
ajax.defineFixture '/api/v1/groups/1/memberships/self',
response:
id: "1"
testStatus: '200'
jqXHR: {}
@gc.send('leave')
equal @gc.get('users').length, 3

View File

@ -0,0 +1,67 @@
define [
'../start_app'
'ember'
'ic-ajax'
'../../controllers/student_groups_controller'
'helpers/fakeENV'
], (startApp, Ember, ajax, StudentGroupsController, fakeENV) ->
App = null
{run} = Ember
module 'student_groups_controller',
setup: ->
App = startApp()
fakeENV.setup()
run => @sgc = StudentGroupsController.create()
groups = null
run =>
groups = [
id: 1
name: "one"
group_category_id: 2
users: [{id: 1, name: "steve"}, {id: 2, name: "cliff"}, {id: 3, name: "walt"}, {id: 4, name: "pinkman"}]
,
id: 2
name: "two"
group_category_id: 1
users: [{id: 1, name: "steve"}, {id: 2, name: "cliff"}, {id: 3, name: "walt"}]
]
@sgc.set('groups', groups)
teardown: ->
run =>
@sgc.destroy()
fakeENV.teardown()
Ember.run App, 'destroy'
test 'Groups are sorted group category id', ->
sorted = @sgc.get('sortedGroups')
equal sorted[0].name, "two"
test 'filterText will filter groups by user name', ->
@sgc.set('filterText', "pink")
sorted = @sgc.get('sortedGroups')
equal sorted.length, 1
equal sorted[0].name, "one"
test 'is member of category should be true if the current user is', ->
ENV.current_user_id = 1
equal @sgc.isMemberOfCategory(1)?, true
test 'is member of category should be false if the current user is not', ->
ENV.current_user_id = 4
equal @sgc.isMemberOfCategory(1)?, false
test 'remove from category removes the user from any group in the category', ->
ENV.current_user_id = 2
equal @sgc.isMemberOfCategory(1)?, true
@sgc.removeFromCategory(1)
equal @sgc.isMemberOfCategory(1)?, false

View File

@ -0,0 +1,19 @@
define ['../main'], (Application) ->
karmaLoaded = window.__karma__
Ember.LOG_VERSION = !karmaLoaded
startApp = ->
App = null
Ember.run.join ->
App = Application.create
LOG_ACTIVE_GENERATION: !karmaLoaded
LOG_MODULE_RESOLVER: !karmaLoaded
LOG_TRANSITIONS: !karmaLoaded
LOG_TRANSITIONS_INTERNAL: !karmaLoaded
LOG_VIEW_LOOKUPS: !karmaLoaded
rootElement: '#fixtures'
history: 'none'
App.Router.reopen history: 'none'
App.setupForTesting()
App.injectTestHelpers()
window.App = App
App

View File

@ -0,0 +1,27 @@
define [
'i18n!roster'
'jquery'
'underscore'
'Backbone'
'jst/courses/roster/rosterTabs'
], (I18n, $, _, Backbone, template) ->
class RosterTabsView extends Backbone.View
template: template
tagName: 'li'
className: 'collectionViewItems ui-tabs-nav ui-helper-reset ui-helper-clearfix ui-widget-header ui-corner-all'
attach: ->
@collection.on 'reset', @render, this
fetch: ->
if ENV.canManageCourse
@collection.fetch()
toJSON: ->
json = {}
json.collection = super
json.course = ENV.course
json

View File

@ -4,7 +4,8 @@ define [
'Backbone'
'jst/courses/roster/index'
'compiled/views/ValidatedMixin'
], (I18n, $, Backbone, template, ValidatedMixin) ->
'compiled/models/GroupCategory'
], (I18n, $, Backbone, template, ValidatedMixin, GroupCategory) ->
class RosterView extends Backbone.View
@ -20,12 +21,15 @@ define [
@child 'resendInvitationsView', '[data-view=resendInvitations]'
@child 'rosterTabsView', '[data-view=rosterTabs]'
@optionProperty 'roles'
@optionProperty 'permissions'
@optionProperty 'course'
template: template
els:
@ -48,6 +52,12 @@ define [
@lastRequest?.abort()
@lastRequest = @collection.fetch().fail @onFail
course_id: ->
ENV.context_asset_string.split('_')[1]
canAddCategories: ->
ENV.canManageCourse
toJSON: -> this
onFail: (xhr) =>

View File

@ -21,6 +21,7 @@ define [
els: _.extend {},
CollectionView::els
'#group_categories_tabs': '$tabs'
'li.static': '$static'
'#add-group-set': '$addGroupSetButton'
'.empty-groupset-instructions': '$emptyInstructions'
@ -32,13 +33,28 @@ define [
tagName: 'li'
template: -> tabTemplate _.extend(@model.present(), id: @model.id ? @model.cid)
render: ->
super
@reorder() if @collection.length > 1
@refreshTabs()
@loadTabFromUrl()
refreshTabs: ->
if @collection.length > 0
@$tabs.find('ul.ui-tabs-nav li.static').remove()
@$tabs.find('ul.ui-tabs-nav').prepend(@$static)
# setup the tabs
if @$tabs.data("tabs")
@$tabs.tabs("refresh").show()
else
@$tabs.tabs({cookie: {}}).show()
@$tabs.tabs
beforeActivate: (event, ui) ->
!ui.newTab.hasClass('static')
# hide/show the instruction text
if @collection.length > 0
@$emptyInstructions.hide()
@ -46,6 +62,27 @@ define [
@$emptyInstructions.show()
# hide the emtpy tab set which may have borders that would otherwise show
@$tabs.hide()
@$tabs.find('li.static a').unbind()
@$tabs.on 'keydown', 'li.static', (event) ->
event.stopPropagation()
if event.keyCode == 13 or event.keyCode == 32
window.location.href = $(this).find('a').attr('href')
loadTabFromUrl: ->
if location.hash == "#new"
@addGroupSet()
else
id = location.hash.split('-')[1]
if id?
model = @collection.get(id)
if model
@$tabs.tabs active: @tabOffsetOfModel(model)
tabOffsetOfModel: (model) ->
index = @collection.indexOf(model)
numStatic = @$static.length
index + numStatic
createItemView: (model) ->
# create and add tab panel
@ -61,7 +98,6 @@ define [
view.render()
@reorder()
@refreshTabs()
@$tabs.tabs active: @collection.indexOf(model)
view
renderItem: ->
@ -76,14 +112,17 @@ define [
@refreshTabs()
addGroupSet: (e) ->
e.preventDefault()
e.preventDefault() if e?
@createView ?= new GroupCategoryCreateView
collection: @collection
trigger: @$addGroupSetButton
cat = new GroupCategory
cat.once 'sync', =>
window.location.hash = "tab-#{cat.id}"
@collection.add(cat)
@$tabs.tabs active: @collection.indexOf(cat)
@reorder()
@refreshTabs()
@$tabs.tabs active: @tabOffsetOfModel(cat)
@createView.model = cat
@createView.open()
@ -102,3 +141,13 @@ define [
# store now loaded
$panel.data('loaded', true)
$panel
toJSON: ->
json = super
json.ENV=ENV
context = ENV.context_asset_string.split('_')
json.context = context[0]
json.isCourse = json.context == "course"
json.context_id = context[1]
json

View File

@ -84,6 +84,7 @@ define [
setUnassignedHeading: ->
count = @model.unassignedUsersCount() ? 0
@unassignedUsersView.render()
@$unassignedUsersHeading.text(
if @model.get('allows_multiple_memberships')
I18n.t('everyone', "Everyone (%{count})", {count})

View File

@ -18,7 +18,7 @@
# @API Groups
#
# Group memberships are the objects that tie users and groups together.
# Group memberships are the objects that tie users and groups together.
#
# @model GroupMembership
# {
@ -88,8 +88,8 @@ class GroupMembershipsController < ApplicationController
# return all memberships.
#
# @example_request
# curl https://<canvas>/api/v1/groups/<group_id>/memberships \
# -F 'filter_states[]=invited&filter_states[]=requested' \
# curl https://<canvas>/api/v1/groups/<group_id>/memberships \
# -F 'filter_states[]=invited&filter_states[]=requested' \
# -H 'Authorization: Bearer <token>'
#
# @returns [GroupMembership]
@ -119,7 +119,7 @@ class GroupMembershipsController < ApplicationController
# @argument user_id [String]
#
# @example_request
# curl https://<canvas>/api/v1/groups/<group_id>/memberships \
# curl https://<canvas>/api/v1/groups/<group_id>/memberships \
# -F 'user_id=self'
# -H 'Authorization: Bearer <token>'
#
@ -150,7 +150,7 @@ class GroupMembershipsController < ApplicationController
# @argument moderator
#
# @example_request
# curl https://<canvas>/api/v1/groups/<group_id>/memberships/<membership_id> \
# curl https://<canvas>/api/v1/groups/<group_id>/memberships/<membership_id> \
# -F 'moderator=true'
# -H 'Authorization: Bearer <token>'
# @example_request
@ -180,8 +180,8 @@ class GroupMembershipsController < ApplicationController
# in place of a membership_id.
#
# @example_request
# curl https://<canvas>/api/v1/groups/<group_id>/memberships/<membership_id> \
# -X DELETE \
# curl https://<canvas>/api/v1/groups/<group_id>/memberships/<membership_id> \
# -X DELETE \
# -H 'Authorization: Bearer <token>'
# @example_request
# curl https://<canvas>/api/v1/groups/<group_id>/users/<user_id> \

View File

@ -288,8 +288,8 @@ class GroupsController < ApplicationController
format.json do
path = send("api_v1_#{@context.class.to_s.downcase}_user_groups_url")
paginated_groups = Api.paginate(@groups, self, path)
render :json => paginated_groups.map { |g| group_json(g, @current_user, session) }
paginated_groups = Api.paginate(all_groups, self, path)
render :json => paginated_groups.map { |g| group_json(g, @current_user, session, :include => Array(params[:include])) }
end
end
end

View File

@ -31,3 +31,77 @@
a.assign_students_link
display: none
.student-groups
border: 1px solid #c1c7cf
border-radius: 3px
.student-group-header
@extend .clearfix
padding: 16px 16px 16px 10px
.icon-mini-arrow-right, .icon-mini-arrow-down
float: left
.icon-mini-arrow-down
display: none
&.show-body
.student-group-header
background-color: #f5f5f5
border-bottom: 1px solid #c1c7cf
cursor: pointer
.icon-mini-arrow-right
display: none
.icon-mini-arrow-down
display: block
.student-group-title
float: left
h3
margin: 0 7px 0 5px
font-weight: bold
font-size: 14px
float: left
line-height: 18px
small
font-size: 12px
a
color: #555
a
position: relative
top: -1px
.student-group-students
float: right
color: #555
font-weight: normal
.student-group-join
float: right
text-transform: uppercase
font-weight: bold
width: 175px
text-align: right
.student-group-body
padding: 16px 16px 16px 36px
&:last-child
border-bottom: 0
.student-group-list
list-style: none
margin: 0
li
float: left
width: 25%
.group-categories-actions
height: 36px
padding-bottom: 10px
z-index: 1
position: relative
#group_categories_tabs
margin: 0 -1em -1em
> .collectionViewItems
padding: 10px 0 0 1em
border-bottom: 0px
> .tab-panel
border-top: 1px solid #aaaaaa
> .roster-tab
padding-left: 1em
padding-right: 1em

View File

@ -14,6 +14,7 @@
<% if @context.is_a?(Course) %>
<% js_bundle :roster %>
<% js_env :canManageCourse => can_do(@context, @current_user, :manage) %>
<% else %>
<% content_for :stylesheets do %>
<style>
@ -48,7 +49,7 @@
<div class="more_info">
<div class="short_name"><%= student.short_name %></div>
<div class="email"><%= student.email %></div>
<% if @enrollments_hash %>
<% if @enrollments_hash %>
<% @enrollments_hash[student.id].each do |e| %>
<div class="course_section"><%= e.try(:course_section).try(:display_name) %></div>
<% end %>
@ -78,7 +79,7 @@
<div class="more_info">
<div class="short_name"><%= teacher.short_name %></div>
<div class="email"><%= teacher.email %></div>
<% if @enrollments_hash %>
<% if @enrollments_hash %>
<% @enrollments_hash[teacher.id].each do |e| %>
<div class="course_section"><%= e.try(:course_section).try(:display_name) %></div>
<% end %>

View File

@ -1,5 +1,10 @@
<% content_for :page_title do %><%= join_title t(:page_title, 'Course Groups'), @context.name %><% end %>
<% if !@context.is_a?(Account) && @context.root_account.feature_enabled?(:student_groups_next) %>
<% js_env :course_id => @context.id %>
<% js_bundle :student_groups %>
<% else %>
<% content_for :right_side do %>
<div class="rs-margin-lr rs-margin-top">
<% if @context.respond_to?(:allow_student_organized_groups) && @context.allow_student_organized_groups %>
@ -9,82 +14,90 @@
<% else %>
<%= t :groups_disabled, 'Student-organized groups have been disabled for this course, so you\'ll have to be content with the groups the teacher makes for you.' %>
<% end %>
<a href="<%= context_url(@context, :context_users_url) %>" class="btn button-sidebar-wide icon-user"><%= @context.is_a?(Account) ? t('#buttons.view_account_roster', 'View Account Roster') : t('#buttons.view_course_roster', 'View Course Roster') %></a>
</div>
<% end %>
<% js_bundle :groups %>
<h2><%= @context.is_a?(Account) ? t('account_groups', 'Account Groups') : t('course_groups', 'Course Groups') %></h2>
<p>
<%= t 'group_explanation', <<-TEXT
Groups are a good place to collaborate on projects or to figure out schedules for study sessions
and the like. Every group gets a calendar, a wiki, discussions, and a little bit of space to store
files. Groups can collaborate on documents, or even schedule web conferences.
It's really like a mini-course where you can work with a smaller number of students on a
more focused project.
TEXT
%>
</p>
<ul class="group_list">
<%= render :partial => 'group', :collection => @user_groups, :locals => {:in_group => true} %>
<%= render :partial => 'group', :object => nil, :locals => {:in_group => true} %>
</ul>
<% if @available_groups && !@available_groups.empty? %>
<h2><%= t 'headings.available_groups', "Available Groups" %></h2>
<ul class="group_list">
<%= render :partial => 'group', :collection => @available_groups, :locals => {:in_categories => @user_groups.map{ |g| g.group_category }.compact.uniq, :in_group => false} %>
<div id="group_categories_tabs" class="ui-tabs-minimal ui-tabs ui-widget ui-widget-content ui-corner-all">
<ul class='collectionViewItems ui-tabs-nav ui-helper-reset ui-helper-clearfix ui-widget-header ui-corner-all'>
<li class="ui-state-default ui-corner-top"><a href="<%= context_url(@context, :context_users_url) %>"><%=t('student_groups_tabs_everyone',"Everyone") %></a></li>
<li class="ui-state-default ui-corner-top ui-tabs-active ui-state-active"><a href="#"><%= t('student_groups_tabs_groups',"Groups") %></a></li>
</ul>
<div class="roster-tab tab-panel">
<h2><%= @context.is_a?(Account) ? t('account_groups', 'Account Groups') : t('course_groups', 'Course Groups') %></h2>
<p>
<%= t 'group_explanation', <<-TEXT
Groups are a good place to collaborate on projects or to figure out schedules for study sessions
and the like. Every group gets a calendar, a wiki, discussions, and a little bit of space to store
files. Groups can collaborate on documents, or even schedule web conferences.
It's really like a mini-course where you can work with a smaller number of students on a
more focused project.
TEXT
%>
</p>
<ul class="group_list">
<%= render :partial => 'group', :collection => @user_groups, :locals => {:in_group => true} %>
<%= render :partial => 'group', :object => nil, :locals => {:in_group => true} %>
</ul>
<% if @available_groups && !@available_groups.empty? %>
<h2><%= t 'headings.available_groups', "Available Groups" %></h2>
<ul class="group_list">
<%= render :partial => 'group', :collection => @available_groups, :locals => {:in_categories => @user_groups.map{ |g| g.group_category }.compact.uniq, :in_group => false} %>
</ul>
<% end %>
<%= form_for :group, :url => context_url(@context, :context_groups_url), :html => {:id => "add_group_form", :style => "display: none;"} do |f| %>
<h2><%= t 'headings.new_group', 'Make a New Group' %></h2>
<%= image_tag "warning.png" %>
<%= t :student_group_warning, <<-TEXT
If your teacher has talked about putting you into
groups as part of an assignment, this is not the way to make that happen.
Groups you organize yourself can't be used for grading... you can still form
your own groups, but you won't be able to turn in an electric copy of any
assignments unless your teacher builds the groups for you.
TEXT
%>
<table class="formtable">
<tr>
<td><%= f.blabel :name, :en => "Group Name" %></td>
<td><%= f.text_field :name %></td>
<% if @context %>
<tr>
<td><%= before_label :restrictions_for_joining_groups, 'Joining' %></td>
<td>
<select id="group_join_level" name="group[join_level]">
<option value="parent_context_auto_join"><%= t 'options.open_to_course_members', 'Course members are free to join' %></option>
<%# Right now the 'parent_context_request' option isn't any different
than 'parent_context_auto_join' because we're auto-accepting requested
memberships. So, we're removing it until we implement an accept request
feature. %>
<option value="invitation_only"><%= t 'options.invite_only', 'Membership by invitation only' %></option>
</select>
</td>
<% end %>
</tr><tr>
<td style="vertical-align: top;">
<%= before_label :users_to_invite, 'Invite' %>
</td>
<td>
<ul class="unstyled_list">
<% (@context.users - [@current_user]).each do |user| %>
<li>
<%= check_box :invitees, "#{user.id}".to_sym, :value => user.id %>
<%= label :invitees, "#{user.id}".to_sym, context_user_name(@context, user) %>
</li>
<% end %>
</ul>
</td>
</tr><tr>
<td colspan="2">
<button type="submit" class="btn submit_button"><%= t 'buttons.create', 'Create Group' %></button>
<button type="button" class="cancel_button btn button-secondary"><%= t '#buttons.cancel', 'Cancel' %></button>
</td>
</table>
</div>
</div>
<% end %>
<% end %>
<%= form_for :group, :url => context_url(@context, :context_groups_url), :html => {:id => "add_group_form", :style => "display: none;"} do |f| %>
<h2><%= t 'headings.new_group', 'Make a New Group' %></h2>
<%= image_tag "warning.png" %>
<%= t :student_group_warning, <<-TEXT
If your teacher has talked about putting you into
groups as part of an assignment, this is not the way to make that happen.
Groups you organize yourself can't be used for grading... you can still form
your own groups, but you won't be able to turn in an electric copy of any
assignments unless your teacher builds the groups for you.
TEXT
%>
<table class="formtable">
<tr>
<td><%= f.blabel :name, :en => "Group Name" %></td>
<td><%= f.text_field :name %></td>
<% if @context %>
<tr>
<td><%= before_label :restrictions_for_joining_groups, 'Joining' %></td>
<td>
<select id="group_join_level" name="group[join_level]">
<option value="parent_context_auto_join"><%= t 'options.open_to_course_members', 'Course members are free to join' %></option>
<%# Right now the 'parent_context_request' option isn't any different
than 'parent_context_auto_join' because we're auto-accepting requested
memberships. So, we're removing it until we implement an accept request
feature. %>
<option value="invitation_only"><%= t 'options.invite_only', 'Membership by invitation only' %></option>
</select>
</td>
<% end %>
</tr><tr>
<td style="vertical-align: top;">
<%= before_label :users_to_invite, 'Invite' %>
</td>
<td>
<ul class="unstyled_list">
<% (@context.users - [@current_user]).each do |user| %>
<li>
<%= check_box :invitees, "#{user.id}".to_sym, :value => user.id %>
<%= label :invitees, "#{user.id}".to_sym, context_user_name(@context, user) %>
</li>
<% end %>
</ul>
</td>
</tr><tr>
<td colspan="2">
<button type="submit" class="btn submit_button"><%= t 'buttons.create', 'Create Group' %></button>
<button type="button" class="cancel_button btn button-secondary"><%= t '#buttons.cancel', 'Cancel' %></button>
</td>
</table>
<% end %>

View File

@ -1,23 +1,29 @@
<div class="form-inline clearfix">
<div class="screenreader-only" id="filter_description">
{{#t "filter_field_description"}}As you type in this field, the list of people will be automatically filtered to only include those whose names match your input.{{/t}}
</div>
<input
{{#if canAddCategories}}
<div class='pull-right group-categories-actions'>
<a href="/courses/{{course_id}}/groups#new" class="btn btn-primary">
<i class='icon-plus'></i> {{#t 'group_set'}}Group Set{{/t}}
</a>
</div>
{{/if}}
<div id="group_categories_tabs" class="ui-tabs-minimal ui-tabs ui-widget ui-widget-content ui-corner-all" style="display: block;">
<ul class="collectionViewItems ui-tabs-nav ui-helper-reset ui-helper-clearfix ui-widget-header ui-corner-all" role="tablist" data-view="rosterTabs"></ul>
<div class="roster-tab tab-panel ui-tabs-panel form-inline" style="height:800px">
<input
type="text"
name="search_term"
data-view="inputFilter"
placeholder='{{#t "search_people"}}Search people{{/t}}'
aria-label='{{#t "filter_list_of_people"}}Filter list of people{{/t}}'
aria-describedby="filter_description"
>
aria-label='{{#t "filter_field_description"}}As you type in this field, the list of people will be automatically filtered to only include those whose names match your input.{{/t}}'
>
<select
name="enrollment_role"
data-view="roleSelect"
aria-label='{{#t "role_to_search"}}Limit search to role{{/t}}'
></select>
<select
name="enrollment_role"
data-view="roleSelect"
aria-label='{{#t "role_to_search"}}Limit search to role{{/t}}'
></select>
{{#if permissions.add_users}}
{{#if permissions.add_users}}
{{#if course.concluded}}
<a
href="#"
@ -27,8 +33,8 @@
title='{{#t "cannot_add_users"}}New users can not be added because this course is concluded{{/t}}'
aria-label='{{#t "cannot_add_users"}}New users can not be added because this course is concluded{{/t}}'
disabled data-tooltip>
{{#t "people"}}People{{/t}}
</a>
{{#t "people"}}People{{/t}}
</a>
{{else}}
<a
href="#"
@ -37,15 +43,16 @@
role="button"
title='{{#t "title_add_people"}}Add People{{/t}}'
aria-label='{{#t "title_add_people"}}Add People{{/t}}'
>{{#t "people"}}People{{/t}}</a>
>{{#t "people"}}People{{/t}}</a>
{{/if}}
{{/if}}
</div>
<form data-view="createUsers" class="form-dialog"></form>
<div class="v-gutter">
<div data-view="resendInvitations"></div>
<div data-view="users"></div>
{{/if}}
<form data-view="createUsers" class="form-dialog"></form>
<div class="v-gutter">
<div data-view="resendInvitations"></div>
<div data-view="users"></div>
</div>
</div>
</div>

View File

@ -0,0 +1,12 @@
<li class="static ui-state-default ui-corner-top ui-tabs-active ui-state-active" >
<a href="/courses/1/users" class="ui-tabs-anchor" >{{#t 'everyone_tab'}}Everyone{{/t}}</a>
</li>
{{#each collection}}
<li class="ui-state-default ui-corner-top" >
<a href="/courses/{{course_id}}/groups#tab-{{id}}" class="group-category-tab-link ui-tabs-anchor">
{{name}}
</a>
</li>
{{else}}
<li class="static ui-state-default ui-corner-top" ><a href="/courses/{{course.id}}/groups" class="ui-tabs-anchor" >{{#t 'groups'}}Groups{{/t}}</a></li>
{{/each}}

View File

@ -31,9 +31,14 @@
</p>
{{/ifEqual}}
</div>
<div style="display:none">
{{#if isCourse}}
<li class="static"><a href="/courses/{{context_id}}/users">{{#t 'tabs.everyone'}}Everyone{{/t}}</a></li>
{{/if}}
</div>
<div id="group_categories_tabs" class="ui-tabs-minimal">
<ul class='collectionViewItems'></ul>
<ul class='collectionViewItems'>
</ul>
<!--panels get inserted dynamically-->
</div>

View File

@ -134,6 +134,17 @@
<% if @context.try(:short_name) %>
<div class='h1' id="section-tabs-header">
<%= @context.short_name %>
<% if @context && @context.is_a?(Group) && can_do(@context, @current_user, :manage)%>
<a class="al-trigger" href="#" style="display:inline-block; float:right" data-popup-within="#wrapper">
<i class="icon-mini-arrow-down"></i>
<span class="screenreader-only"><%= t("group_switch","Switch")%></span>
</a>
<ul class="al-options">
<% @context.group_category.groups.active.by_name.each do |group| %>
<li><%= link_to group.short_name, group_path(group) %></li>
<% end %>
</ul>
<% end %>
<% if @context && @context.respond_to?(:context) %>
<a href="<%= url_for(@context.context) %>" id="section-tabs-header-subtitle" class="ellipsis"><%= @context.context.name %></a>
<% elsif @context && @context.respond_to?(:enrollment_term) && !@context.enrollment_term.default_term? %>

View File

@ -48,6 +48,9 @@ module Api::V1::Group
# TODO: this should be switched to user_display_json
hash['users'] = group.users.map{ |u| user_json(u, user, session) }
end
if includes.include?('group_category')
hash['group_category'] = group_category_json(group.group_category, user, session)
end
hash['html_url'] = group_url(group) if includes.include? 'html_url'
hash['sis_group_id'] = group.sis_source_id if group.context_type == 'Account' && group.root_account.grants_rights?(user, session, :read_sis, :manage_sis).values.any?
hash['sis_import_id'] = group.sis_batch_id if group.context_type == 'Account' && group.root_account.grants_right?(user, session, :manage_sis)

View File

@ -23,7 +23,7 @@ module Api::V1::User
API_USER_JSON_OPTS = {
:only => %w(id name),
:methods => %w(sortable_name short_name)
:methods => %w(sortable_name short_name display_name)
}
def user_json_preloads(users, preload_email=false)

View File

@ -205,6 +205,18 @@ END
state: 'hidden',
root_opt_in: true,
development: true
},
'student_groups_next' =>
{
display_name: -> { I18n.t('features.student_groups', 'New Student Groups Page') },
description: -> { I18n.t('student_groups_desc', <<-END) },
This enables the new student group page for an account. The new page was build to provide a more dynamic group signup
experience.
END
applies_to: 'RootAccount',
state: 'allowed',
root_opt_in: true,
development: true
}
)

View File

@ -134,7 +134,7 @@ describe "Groups API", type: :request do
response.code.should == '401'
end
it "should limit students to their own groups" do
it "should show students all groups" do
course_with_student(:active_all => true)
@group_1 = @course.groups.create!(:name => 'Group 1')
@group_2 = @course.groups.create!(:name => 'Group 2')
@ -143,7 +143,7 @@ describe "Groups API", type: :request do
json = api_call(:get, "/api/v1/courses/#{@course.to_param}/groups.json",
@category_path_options.merge(:action => 'context_index',
:course_id => @course.to_param))
json.count.should == 1
json.count.should == 2
json.first['id'].should == @group_1.id
end
@ -153,6 +153,12 @@ describe "Groups API", type: :request do
json.should == group_json(@community)
end
it "should include the group category" do
@user = @member
json = api_call(:get, "#{@community_path}.json?include[]=group_category", @category_path_options.merge(:group_id => @community.to_param, :action => "show", :include => [ "group_category" ]))
json.has_key?("group_category").should be_true
end
it 'should include permissions' do
# Make sure it only returns permissions when asked
json = api_call(:get, @community_path, @category_path_options.merge(:group_id => @community.to_param, :action => "show", :format => 'json'))

View File

@ -123,4 +123,30 @@ describe "groups" do
new_group_el.find_elements(:css, ".student").length.should == 2
end
describe "new groups page" do
it "should allow a student to create a group" do
course_with_student_logged_in(:active_all => true)
@course.root_account.enable_feature!(:student_groups_next)
student_in_course
student_in_course
get "/courses/#{@course.id}/groups"
wait_for_ajaximations
keep_trying_until do
f(".add_group_link").click
wait_for_animations
end
f("#group_name").send_keys("My Group")
ff("#group_join_level option").length.should == 2
f("#invitees_#{@student.id}").click
fj('button.confirm-dialog-confirm-btn').click
wait_for_ajaximations
new_group_el = fj(".student-group-header:first").text
new_group_el.should include "My Group"
new_group_el.should include "1 student"
end
end
end

View File

@ -24,13 +24,14 @@ describe "manage groups" do
f('#add-group-set').click
set_value f('#new_category_name'), "zomg"
f('[name=split_groups]').click
set_value f('[name=create_group_count]'), 2
driver.execute_script("$('[name=create_group_count]:enabled').val(2)")
submit_form f('.group-category-create')
wait_for_ajaximations
# yay, added
f('#group_categories_tabs .collectionViewItems').text.should == 'zomg'
f('#group_categories_tabs .collectionViewItems').text.should include('Everyone')
f('#group_categories_tabs .collectionViewItems').text.should include('zomg')
run_jobs
@ -60,7 +61,7 @@ describe "manage groups" do
wait_for_ajaximations
# verify the group set tab is created
fj("#group_categories_tabs li[role='tab']:first").text.should == 'Group Set 1'
fj("#group_categories_tabs li[role='tab']:nth-child(2)").text.should == 'Group Set 1'
# verify has the two created but unassigned students
ff("div[data-view='unassignedUsers'] .group-user-name").length.should == 2

View File

@ -87,6 +87,10 @@ describe "people" do
wait_for_ajaximations
end
it "should have tabs" do
fj('.collectionViewItems>li:first').text.should match "Everyone"
end
it "should validate the main page" do
users = ff('.roster_user_name')
users[1].text.should match @student_1.name