learning mastery student view: outcome hierarchy

fixes CNVS-13307

test plan:
  * enable learning mastery student view
  * open a student's grades page
  * open the learning mastery tab
  * verify that outcome groups are presented in a hierarchy
  * verify that very deep outcome trees are compressed to paths

Change-Id: I1f74dca2876b39b01a6ec70935f60d5e233fe02b
Reviewed-on: https://gerrit.instructure.com/35481
Tested-by: Jenkins <jenkins@instructure.com>
Reviewed-by: Drew Bowman <dbowman@instructure.com>
QA-Review: Steven Shepherd <sshepherd@instructure.com>
Product-Review: Braden Anderson <banderson@instructure.com>
This commit is contained in:
Braden Anderson 2014-05-28 01:54:28 -06:00
parent 47434d86ae
commit 805510f0ad
20 changed files with 299 additions and 65 deletions

View File

@ -4,11 +4,12 @@ require [
'Backbone'
'compiled/views/CollectionView'
'compiled/userSettings'
'compiled/views/gradebook/StudentOutcomeView'
'compiled/collections/OutcomeSummaryCollection'
'compiled/views/grade_summary/SectionView'
'jqueryui/tabs'
'jquery.disableWhileLoading'
'grade_summary'
], ($, _, Backbone, CollectionView, userSettings, StudentOutcomeView) ->
], ($, _, Backbone, CollectionView, userSettings, OutcomeSummaryCollection, SectionView) ->
class GradebookSummaryRouter extends Backbone.Router
routes:
'': 'tab'
@ -18,6 +19,11 @@ require [
return unless ENV.student_outcome_gradebook_enabled
$('#content').tabs(activate: @activate)
course_id = ENV.context_asset_string.replace('course_', '')
user_id = ENV.student_id
@outcomes = new OutcomeSummaryCollection([], course_id: course_id, user_id: user_id)
@outcomeView = new CollectionView(el: $('#outcomes'), collection: @outcomes, itemView: SectionView)
tab: (tab) ->
if tab != 'outcomes' && tab != 'assignments'
tab = userSettings.contextGet('grade_summary_tab') || 'assignments'
@ -26,30 +32,12 @@ require [
activate: (event, ui) =>
tab = ui.newPanel.attr('id')
router.navigate("#tab-#{tab}")
@loadOutcomes() if tab == 'outcomes'
@fetchOutcomes() if tab == 'outcomes'
userSettings.contextSet('grade_summary_tab', tab)
loadOutcomes: ->
@loadOutcomes = $.noop
course_id = ENV.context_asset_string.replace('course_', '')
user_id = ENV.student_id
url = "/api/v1/courses/#{course_id}/outcome_rollups?user_ids[]=#{user_id}&include[]=outcomes"
whenLoaded = $.getJSON(url)
$('#outcomes').disableWhileLoading(whenLoaded)
whenLoaded.done(@handleOutcomes)
handleOutcomes: (response) =>
scores = _.object(_.map(response.rollups[0].scores, (score) ->
[score.links.outcome, score.score]
))
outcomes = new Backbone.Collection(_.map(response.linked.outcomes, (outcome) ->
new Backbone.Model(_.extend({score: scores[outcome.id]}, outcome))
))
new CollectionView(
el: $('#outcomes')
itemView: StudentOutcomeView
collection: outcomes
).render()
fetchOutcomes: ->
@fetchOutcomes = $.noop
@outcomes.fetch()
@router = new GradebookSummaryRouter
Backbone.history.start()

View File

@ -0,0 +1,80 @@
define [
'jquery'
'underscore'
'Backbone'
'compiled/models/grade_summary/Section'
'compiled/models/grade_summary/Group'
'compiled/models/grade_summary/Outcome'
'compiled/collections/PaginatedCollection'
'compiled/collections/WrappedCollection'
'compiled/util/natcompare'
], ($, _, {Collection}, Section, Group, Outcome, PaginatedCollection, WrappedCollection, natcompare) ->
class GroupCollection extends PaginatedCollection
@optionProperty 'course_id'
model: Group
url: -> "/api/v1/courses/#{@course_id}/outcome_groups"
class LinkCollection extends PaginatedCollection
@optionProperty 'course_id'
url: -> "/api/v1/courses/#{@course_id}/outcome_group_links?outcome_style=full"
class ResultCollection extends WrappedCollection
@optionProperty 'course_id'
@optionProperty 'user_id'
key: 'rollups'
url: -> "/api/v1/courses/#{@course_id}/outcome_rollups?user_ids[]=#{@user_id}"
class OutcomeSummaryCollection extends Collection
@optionProperty 'course_id'
@optionProperty 'user_id'
comparator: natcompare.byGet('path')
initialize: ->
super
@rawCollections =
groups: new GroupCollection([], course_id: @course_id)
links: new LinkCollection([], course_id: @course_id)
results: new ResultCollection([], course_id: @course_id, user_id: @user_id)
fetch: ->
dfd = $.Deferred()
requests = _.values(@rawCollections).map (collection) -> collection.loadAll = true; collection.fetch()
$.when.apply($, requests).done(=> @processCollections(dfd))
dfd
scores: ->
studentResults = @rawCollections.results.at(0).get('scores')
pairs = studentResults.map((x) -> [x.links.outcome, x.score])
_.object(pairs)
populateGroupOutcomes: ->
scores = @scores()
@rawCollections.links.each (link) =>
outcome = new Outcome(link.get('outcome'))
outcome.set('score', scores[outcome.id])
parent = @rawCollections.groups.get(link.get('outcome_group').id)
parent.get('outcomes').add(outcome)
populateSectionGroups: ->
tmp = new Collection()
@rawCollections.groups.each (group) =>
return unless group.get('outcomes').length
parentObj = group.get('parent_outcome_group')
parentId = if parentObj then parentObj.id else group
unless parent = tmp.get(parentId)
parent = tmp.add(new Section(id: parentId, path: @getPath(parentId)))
parent.get('groups').add(group)
@reset(tmp.models)
processCollections: (dfd) =>
@populateGroupOutcomes()
@populateSectionGroups()
dfd.resolve(@models)
getPath: (id) ->
group = @rawCollections.groups.get(id)
parent = group.get('parent_outcome_group')
return '' unless parent
parentPath = @getPath(parent.id)
(if parentPath then parentPath + ': ' else '') + group.get('title')

View File

@ -66,6 +66,7 @@ define [
@_setStateAfterFetch(xhr, options)
data
dfd = options.dfd || $.Deferred()
xhr = super(options).done (response, text, xhr) =>
@trigger 'fetch', this, response, options
@trigger "fetch:#{options.page}", this, response, options if options.page?
@ -74,7 +75,13 @@ define [
@loadedAll = true
if @loadAll and @urls.next?
setTimeout =>
@fetch page: 'next' # next tick so we can show loading indicator, etc.
@fetch page: 'next', dfd: dfd # next tick so we can show loading indicator, etc.
else
dfd.resolve(response, text, xhr)
dfd.abort = xhr.abort
dfd.success = dfd.done
dfd.error = dfd.fail
dfd
canFetch: (page) ->
@urls? and @urls[page]?

View File

@ -0,0 +1,11 @@
define [
'jquery'
'Backbone'
'compiled/collections/PaginatedCollection'
], ($, Backbone, PaginatedCollection) ->
class WrappedCollection extends PaginatedCollection
@optionProperty 'key'
parse: (response) ->
@linked = response.linked
response[@key]

View File

@ -0,0 +1,18 @@
define [
'underscore'
'Backbone'
'compiled/util/natcompare'
], (_, {Model, Collection}, natcompare) ->
class Group extends Model
initialize: ->
@set('outcomes', new Collection([], comparator: natcompare.byGet('title')))
count: -> @get('outcomes').length
mastery_count: ->
@get('outcomes').filter((x) ->
x.status() == 'mastery'
).length
toJSON: ->
_.extend(super, count: @count(), mastery_count: @mastery_count())

View File

@ -0,0 +1,17 @@
define [
'underscore'
'Backbone'
], (_, {Model, Collection}) ->
class Outcome extends Model
status: ->
score = @get('score')
mastery = @get('mastery_points')
if score >= mastery
'mastery'
else if score >= mastery / 2
'near'
else
'remedial'
toJSON: ->
_.extend(super, status: @status())

View File

@ -0,0 +1,8 @@
define [
'underscore'
'Backbone'
'compiled/util/natcompare'
], (_, {Model, Collection}, natcompare) ->
class Section extends Model
initialize: ->
@set('groups', new Collection([], comparator: natcompare.byGet('title')))

View File

@ -0,0 +1,11 @@
define [], ->
strings: (x, y) ->
x.localeCompare(y, window.I18n.locale, { sensitivity: 'accent', ignorePunctuation: true, numeric: true})
by: (f) ->
return (x, y) =>
@strings(f(x), f(y))
byKey: (key) -> @by((x) -> x[key])
byGet: (key) -> @by((x) -> x.get(key))

View File

@ -0,0 +1,24 @@
define [
'Backbone'
'underscore'
'compiled/views/CollectionView'
'compiled/views/grade_summary/OutcomeView'
'jst/grade_summary/group'
], ({View, Collection}, _, CollectionView, OutcomeView, template) ->
class GroupView extends View
tagName: 'li'
className: 'group'
els:
'.outcomes': '$outcomes'
template: template
render: ->
super
outcomesView = new CollectionView
el: @$outcomes
collection: @model.get('outcomes')
itemView: OutcomeView
outcomesView.render()

View File

@ -1,10 +1,11 @@
define [
'underscore'
'Backbone'
'jst/gradebook2/student_outcome_view'
'jst/grade_summary/outcome'
], (_, Backbone, template) ->
class StudentOutcomesView extends Backbone.View
class OutcomeView extends Backbone.View
tagName: 'li'
className: 'outcome'
template: template
toJSON: ->
json = super

View File

@ -0,0 +1,24 @@
define [
'Backbone'
'underscore'
'compiled/views/CollectionView'
'compiled/views/grade_summary/GroupView'
'jst/grade_summary/section'
], ({View, Collection}, _, CollectionView, GroupView, template) ->
class SectionView extends View
tagName: 'li'
className: 'section'
els:
'.groups': '$groups'
template: template
render: ->
super
groupsView = new CollectionView
el: @$groups
collection: @model.get('groups')
itemView: GroupView
groupsView.render()

View File

@ -122,7 +122,7 @@
# },
# "outcome": {
# "description": "an abbreviated Outcome object representing the outcome linked into the containing outcome group.",
# "$ref": "OutcomeGroup"
# "$ref": "Outcome"
# }
# }
# }
@ -162,13 +162,23 @@ class OutcomeGroupsApiController < ApplicationController
# @API Get all outcome links for context
# @beta
#
# @argument outcome_style [Optional, String]
# The detail level of the outcomes. Defaults to "abbrev".
# Specify "full" for more information.
#
# @argument outcome_group_style [Optional, String]
# The detail level of the outcome groups. Defaults to "abbrev".
# Specify "full" for more information.
#
# @returns [OutcomeLink]
def link_index
return unless can_read_outcomes
url = polymorphic_url [:api_v1, @context || :global, :outcome_group_links]
links = Api.paginate(context_outcome_links, self, url)
render json: links.map { |link| outcome_link_json(link, @current_user, session) }
render json: links.map { |link|
outcome_link_json(link, @current_user, session, params.slice(:outcome_style, :outcome_group_style))
}
end
# @API Show an outcome group

View File

@ -432,7 +432,7 @@ class OutcomeResultsController < ApplicationController
if params[:user_ids]
user_ids = Api.value_to_array(params[:user_ids]).map(&:to_i).uniq
@users = users_for_outcome_context.where(id: user_ids)
@users = users_for_outcome_context.where(id: user_ids).uniq
reject!( "can only include id's of users in the outcome context") if @users.count != user_ids.count
elsif params[:section_id]
@section = @context.course_sections.where(id: params[:section_id].to_i).first

View File

@ -295,32 +295,54 @@ div.rubric-toggle
$outcome-border: 1px solid #BCC2CA
#outcomes ul
background-color: #f4f4f4
border: $outcome-border
border-radius: 3px
overflow: hidden
#outcomes
h2
font-size: 1.4em
font-weight: bold
#outcomes li
list-style-type: none
overflow: hidden
border-top: $outcome-border
.group
border: $outcome-border
border-radius: 3px
margin-top: 1em
&:first-child
border-top: none
.outcome
h3
font-size: 1.2em
margin-left: 1em
float: left
width: 80%
padding: 1em
border-right: $outcome-border
box-sizing: border-box
.description
color: #b4b4b4
.group-description
overflow: hidden
.status
float: right
margin: 1em
.score
float: right
width: 20%
padding: 1em
box-sizing: border-box
.outcomes
background-color: #f7f7f7
ul
margin-left: 0
li
list-style-type: none
overflow: hidden
li.outcome
border-top: $outcome-border
.outcome-properties
float: left
width: 80%
padding: 1em
box-sizing: border-box
.description
color: #b4b4b4
.title
font-weight: bold
.score
float: right
width: 20%
padding: 1em
box-sizing: border-box

View File

@ -0,0 +1,8 @@
<div class="group-description">
<h3>{{title}}</h3>
<div class="status">
{{mastery_count}} / {{count}}
</div>
</div>
<div class="outcomes">
</div>

View File

@ -0,0 +1,7 @@
<div class="outcome-properties">
<div class="title">{{title}}</div>
<div class="description">{{{description}}}</div>
</div>
<div class="score">
{{#if score_defined}}{{score}}{{else}}-{{/if}}/{{mastery_points}}
</div>

View File

@ -0,0 +1,3 @@
<h2>{{path}}</h2>
<div class="groups">
</div>

View File

@ -1,7 +0,0 @@
<div class="outcome">
<div class="title">{{title}}</div>
<div class="description">{{{description}}}</div>
</div>
<div class="score">{{#if score_defined}}
{{score}}/{{points_possible}}
{{/if}}</div>

View File

@ -75,16 +75,18 @@ module Api::V1::Outcome
end
end
def outcome_link_json(outcome_link, user, session)
def outcome_link_json(outcome_link, user, session, opts={})
opts[:outcome_style] ||= :abbrev
opts[:outcome_group_style] ||= :abbrev
api_json(outcome_link, user, session, :only => %w(context_type context_id)).tap do |hash|
hash['url'] = polymorphic_path [:api_v1, outcome_link.context || :global, :outcome_link],
:id => outcome_link.associated_asset_id,
:outcome_id => outcome_link.content_id
hash['outcome_group'] = outcome_group_json(outcome_link.associated_asset, user, session, :abbrev)
hash['outcome_group'] = outcome_group_json(outcome_link.associated_asset, user, session, opts[:outcome_group_style])
# use learning_outcome_content vs. content in case
# learning_outcome_content has been preloaded (e.g. by
# ContentTag.order_by_outcome_title)
hash['outcome'] = outcome_json(outcome_link.learning_outcome_content, user, session, :abbrev)
hash['outcome'] = outcome_json(outcome_link.learning_outcome_content, user, session, opts[:outcome_style])
end
end
end

View File

@ -106,7 +106,7 @@ describe "grades" do
f('#navpills').should_not be_nil
f('a[href="#outcomes"]').click
wait_for_ajaximations
ff('#outcomes li').count.should == @course.learning_outcome_links.count
ff('#outcomes li.outcome').count.should == @course.learning_outcome_links.count
end
context 'student view' do