Add reporting name for learning outcomes

fixes CNVS-13242

This adds a special field to learning outcomes
to use for friendly reporting in case the outcome's
actual name is quite complex or cryptic (like common core
standard outcomes).

TEST PLAN:
- login as an instructor
- create or edit an outcome and validate that you can add a "friendly"
  name and that it persists
- use that outcome for an assignment and let a student complete it
- navigate to the student mastery report for that student
- verify that by hovering over that outcome name on the student mastery
report you can see both the real title and the friendly name

Change-Id: I89d1a5de590666ddf6cbc82617e4475d1f7a5226
Reviewed-on: https://gerrit.instructure.com/35919
Reviewed-by: Drew Bowman <dbowman@instructure.com>
QA-Review: Steven Shepherd <sshepherd@instructure.com>
Tested-by: Jenkins <jenkins@instructure.com>
Product-Review: Drew Bowman <dbowman@instructure.com>
This commit is contained in:
Ethan Vizitei 2014-06-04 15:39:03 -05:00 committed by Drew Bowman
parent ee1db6929c
commit a0c96d6a41
16 changed files with 120 additions and 22 deletions

View File

@ -5,7 +5,7 @@ define [
], (_, {Model, Collection}, natcompare) -> ], (_, {Model, Collection}, natcompare) ->
class Group extends Model class Group extends Model
initialize: -> initialize: ->
@set('outcomes', new Collection([], comparator: natcompare.byGet('title'))) @set('outcomes', new Collection([], comparator: natcompare.byGet('friendly_name')))
count: -> @get('outcomes').length count: -> @get('outcomes').length

View File

@ -3,6 +3,11 @@ define [
'Backbone' 'Backbone'
], (_, {Model, Collection}) -> ], (_, {Model, Collection}) ->
class Outcome extends Model class Outcome extends Model
initialize: ->
super
@set 'friendly_name', @get('display_name') || @get('title')
@set 'hover_name', (@get('title') if @get('display_name'))
status: -> status: ->
if @scoreDefined() if @scoreDefined()
score = @get('score') score = @get('score')

View File

@ -384,6 +384,10 @@ class OutcomeGroupsApiController < ApplicationController
# @argument title [Optional, String] # @argument title [Optional, String]
# The title of the new outcome. Required if outcome_id is absent. # The title of the new outcome. Required if outcome_id is absent.
# #
# @argument display_name [Optional, String]
# A friendly name shown in reports for outcomes with cryptic titles,
# such as common core standards names.
#
# @argument description [Optional, String] # @argument description [Optional, String]
# The description of the new outcome. # The description of the new outcome.
# #
@ -412,6 +416,7 @@ class OutcomeGroupsApiController < ApplicationController
# curl 'https://<canvas>/api/v1/accounts/1/outcome_groups/1/outcomes.json' \ # curl 'https://<canvas>/api/v1/accounts/1/outcome_groups/1/outcomes.json' \
# -X POST \ # -X POST \
# -F 'title=Outcome Title' \ # -F 'title=Outcome Title' \
# -F 'display_name=Title for reporting' \
# -F 'description=Outcome description' \ # -F 'description=Outcome description' \
# -F 'vendor_guid=customid9000' \ # -F 'vendor_guid=customid9000' \
# -F 'mastery_points=3' \ # -F 'mastery_points=3' \
@ -429,6 +434,7 @@ class OutcomeGroupsApiController < ApplicationController
# -X POST \ # -X POST \
# --data-binary '{ # --data-binary '{
# "title": "Outcome Title", # "title": "Outcome Title",
# "display_name": "Title for reporting",
# "description": "Outcome description", # "description": "Outcome description",
# "vendor_guid": "customid9000", # "vendor_guid": "customid9000",
# "mastery_points": 3, # "mastery_points": 3,
@ -451,7 +457,7 @@ class OutcomeGroupsApiController < ApplicationController
return return
end end
else else
@outcome = context_create_outcome(params.slice(:title, :description, :ratings, :mastery_points, :vendor_guid)) @outcome = context_create_outcome(params.slice(:title, :description, :ratings, :mastery_points, :vendor_guid, :display_name))
unless @outcome.valid? unless @outcome.valid?
render :json => @outcome.errors, :status => :bad_request render :json => @outcome.errors, :status => :bad_request
return return
@ -666,7 +672,7 @@ class OutcomeGroupsApiController < ApplicationController
def context_create_outcome(data) def context_create_outcome(data)
scope = @context ? @context.created_learning_outcomes : LearningOutcome.global scope = @context ? @context.created_learning_outcomes : LearningOutcome.global
outcome = scope.build(data.slice(:title, :description, :vendor_guid)) outcome = scope.build(data.slice(:title, :display_name, :description, :vendor_guid))
if data[:ratings] if data[:ratings]
outcome.rubric_criterion = data.slice(:ratings, :mastery_points) outcome.rubric_criterion = data.slice(:ratings, :mastery_points)
end end

View File

@ -49,6 +49,11 @@
# "example": "Outcome title", # "example": "Outcome title",
# "type": "string" # "type": "string"
# }, # },
# "display_name": {
# "description": "Optional friendly name for reporting",
# "example": "My Favorite Outocome",
# "type": "string"
# },
# "description": { # "description": {
# "description": "description of the outcome. omitted in the abbreviated form.", # "description": "description of the outcome. omitted in the abbreviated form.",
# "example": "Outcome description", # "example": "Outcome description",
@ -118,6 +123,10 @@ class OutcomesApiController < ApplicationController
# @argument title [Optional, String] # @argument title [Optional, String]
# The new outcome title. # The new outcome title.
# #
# @argument display_name [Optional, String]
# A friendly name shown in reports for outcomes with cryptic titles,
# such as common core standards names.
#
# @argument description [Optional, String] # @argument description [Optional, String]
# The new outcome description. # The new outcome description.
# #
@ -141,6 +150,7 @@ class OutcomesApiController < ApplicationController
# curl 'https://<canvas>/api/v1/outcomes/1.json' \ # curl 'https://<canvas>/api/v1/outcomes/1.json' \
# -X PUT \ # -X PUT \
# -F 'title=Outcome Title' \ # -F 'title=Outcome Title' \
# -F 'display_name=Title for reporting' \
# -F 'description=Outcome description' \ # -F 'description=Outcome description' \
# -F 'vendor_guid=customid9001' \ # -F 'vendor_guid=customid9001' \
# -F 'mastery_points=3' \ # -F 'mastery_points=3' \
@ -158,6 +168,7 @@ class OutcomesApiController < ApplicationController
# -X PUT \ # -X PUT \
# --data-binary '{ # --data-binary '{
# "title": "Outcome Title", # "title": "Outcome Title",
# "display_name": "Title for reporting",
# "description": "Outcome description", # "description": "Outcome description",
# "vendor_guid": "customid9001", # "vendor_guid": "customid9001",
# "mastery_points": 3, # "mastery_points": 3,
@ -172,7 +183,7 @@ class OutcomesApiController < ApplicationController
# #
def update def update
if authorized_action(@outcome, @current_user, :update) if authorized_action(@outcome, @current_user, :update)
@outcome.update_attributes(params.slice(:title, :description, :vendor_guid)) @outcome.update_attributes(params.slice(:title, :display_name, :description, :vendor_guid))
if params[:mastery_points] || params[:ratings] if params[:mastery_points] || params[:ratings]
criterion = @outcome.data && @outcome.data[:rubric_criterion] criterion = @outcome.data && @outcome.data[:rubric_criterion]
criterion ||= {} criterion ||= {}

View File

@ -18,7 +18,9 @@
class LearningOutcome < ActiveRecord::Base class LearningOutcome < ActiveRecord::Base
include Workflow include Workflow
attr_accessible :context, :description, :short_description, :title, :rubric_criterion, :vendor_guid attr_accessible :context, :description, :short_description, :title, :display_name
attr_accessible :rubric_criterion, :vendor_guid
belongs_to :context, :polymorphic => true belongs_to :context, :polymorphic => true
validates_inclusion_of :context_type, :allow_nil => true, :in => ['Account', 'Course'] validates_inclusion_of :context_type, :allow_nil => true, :in => ['Account', 'Course']
has_many :learning_outcome_results has_many :learning_outcome_results
@ -50,14 +52,14 @@ class LearningOutcome < ActiveRecord::Base
given {|user, session| self.context_id.nil? && user } given {|user, session| self.context_id.nil? && user }
can :read can :read
end end
def infer_defaults def infer_defaults
if self.data && self.data[:rubric_criterion] if self.data && self.data[:rubric_criterion]
self.data[:rubric_criterion][:description] = self.short_description self.data[:rubric_criterion][:description] = self.short_description
end end
self.context_code = "#{self.context_type.underscore}_#{self.context_id}" rescue nil self.context_code = "#{self.context_type.underscore}_#{self.context_id}" rescue nil
end end
def align(asset, context, opts={}) def align(asset, context, opts={})
tag = self.alignments.find_by_content_id_and_content_type_and_tag_type_and_context_id_and_context_type(asset.id, asset.class.to_s, 'learning_outcome', context.id, context.class.to_s) tag = self.alignments.find_by_content_id_and_content_type_and_tag_type_and_context_id_and_context_type(asset.id, asset.class.to_s, 'learning_outcome', context.id, context.class.to_s)
tag ||= self.alignments.create(:content => asset, :tag_type => 'learning_outcome', :context => context) tag ||= self.alignments.create(:content => asset, :tag_type => 'learning_outcome', :context => context)
@ -73,7 +75,7 @@ class LearningOutcome < ActiveRecord::Base
tag.save tag.save
tag tag
end end
def reorder_alignments(context, order) def reorder_alignments(context, order)
order_hash = {} order_hash = {}
order.each_with_index{|o, i| order_hash[o.to_i] = i; order_hash[o] = i } order.each_with_index{|o, i| order_hash[o.to_i] = i; order_hash[o] = i }
@ -88,7 +90,7 @@ class LearningOutcome < ActiveRecord::Base
self.touch self.touch
tags tags
end end
def remove_alignment(asset, context, opts={}) def remove_alignment(asset, context, opts={})
tag = self.alignments.find_by_content_id_and_content_type_and_tag_type_and_context_id_and_context_type(asset.id, asset.class.to_s, 'learning_outcome', context.id, context.class.to_s) tag = self.alignments.find_by_content_id_and_content_type_and_tag_type_and_context_id_and_context_type(asset.id, asset.class.to_s, 'learning_outcome', context.id, context.class.to_s)
tag.destroy if tag tag.destroy if tag
@ -123,7 +125,7 @@ class LearningOutcome < ActiveRecord::Base
def title=(new_title) def title=(new_title)
self.short_description = new_title self.short_description = new_title
end end
workflow do workflow do
state :active state :active
state :retired state :retired
@ -135,7 +137,7 @@ class LearningOutcome < ActiveRecord::Base
self.context.short_name rescue "" self.context.short_name rescue ""
end end
end end
def rubric_criterion=(hash) def rubric_criterion=(hash)
self.data ||= {} self.data ||= {}
@ -178,11 +180,11 @@ class LearningOutcome < ActiveRecord::Base
self.workflow_state = 'deleted' self.workflow_state = 'deleted'
save! save!
end end
def tie_to(context) def tie_to(context)
@tied_context = context @tied_context = context
end end
def artifacts_count_for_tied_context def artifacts_count_for_tied_context
codes = [@tied_context.asset_string] codes = [@tied_context.asset_string]
if @tied_context.is_a?(Account) if @tied_context.is_a?(Account)

View File

@ -361,6 +361,9 @@ $outcome-border: 1px solid #BCC2CA
color: #2a333b color: #2a333b
p p
margin: 0 margin: 0
white-space: nowrap
overflow: hidden
text-overflow: ellipsis
.title .title
font-weight: bold font-weight: bold
@ -445,3 +448,6 @@ $outcome-border: 1px solid #BCC2CA
top: 13px top: 13px
.score .score
font-size: 14px font-size: 14px
.ui-widget.ui-tooltip
max-width: 500px

View File

@ -33,6 +33,9 @@
height: 500px height: 500px
border-left: 1px solid $borderColor border-left: 1px solid $borderColor
overflow: scroll overflow: scroll
.learning_outcome
label.span3
margin-left: 0px
.wrapper .wrapper
padding: 15px padding: 15px
width: 600px width: 600px

View File

@ -14,7 +14,9 @@
{{/if}} {{/if}}
</div> </div>
<div class="outcome-properties"> <div class="outcome-properties">
<div class="title" data-tooltip title="{{description}}">{{title}}</div> <div class="title" data-tooltip title="{{#if hover_name}}{{hover_name}}: {{/if}}{{description}}">
{{friendly_name}}
</div>
<div class="description">{{{description}}}</div> <div class="description">{{{description}}}</div>
</div> </div>
<div class="outcome-score outcome-bar-wrapper"> <div class="outcome-score outcome-bar-wrapper">

View File

@ -1,5 +1,7 @@
<div class="outcome-modal"> <div class="outcome-modal">
<div class="title">{{title}}</div> <div class="title"{{#if hover_name}} data-tooltip title="{{hover_name}}"{{/if}}>
{{friendly_name}}
</div>
<div class="outcome-bar-wrapper"> <div class="outcome-bar-wrapper">
<div class="score"><strong>{{#if scoreDefined}}{{score}}{{else}}-{{/if}}</strong>/{{mastery_points}}</div> <div class="score"><strong>{{#if scoreDefined}}{{score}}{{else}}-{{/if}}</strong>/{{mastery_points}}</div>
{{view progress}} {{view progress}}

View File

@ -1,6 +1,12 @@
<form action="{{url}}" class="learning_outcome" method="post"> <form action="{{url}}" class="learning_outcome" method="post">
<label for="title">{{#t "title"}}Name this outcome{{/t}}:</label>
<input class="outcome_title" name="title" id=title size="50" type="text" value="{{title}}"> <label class="span3" for="title">{{#t "title"}}Name this outcome{{/t}}:</label>
<input class="outcome_title span3" name="title" id=title size="50" type="text" value="{{title}}">
<label class="span3" for="display_name">{{#t "display_name"}}Friendly name (optional){{/t}}:</label>
<input class="outcome_display_name span3" name="display_name" id=display_name size="50" type="text" value="{{display_name}}">
<label for="description">{{#t "description"}}Describe this outcome{{/t}}:</label> <label for="description">{{#t "description"}}Describe this outcome{{/t}}:</label>
<textarea cols="40" name="description" id=description rows="20" style="width: 100%; height: 150px;">{{description}}</textarea> <textarea cols="40" name="description" id=description rows="20" style="width: 100%; height: 150px;">{{description}}</textarea>
<div id="outcome_criterion_dialog" style="display: none;"> <div id="outcome_criterion_dialog" style="display: none;">

View File

@ -0,0 +1,11 @@
class AddDisplayNameToLearningOutcomes < ActiveRecord::Migration
tag :predeploy
def self.up
add_column :learning_outcomes, :display_name, :string
end
def self.down
remove_column :learning_outcomes, :display_name
end
end

View File

@ -32,7 +32,8 @@ module Api::V1::Outcome
# can_edit. full expands on that by adding description and criterion values # can_edit. full expands on that by adding description and criterion values
# (if any). # (if any).
def outcome_json(outcome, user, session, style=:full) def outcome_json(outcome, user, session, style=:full)
api_json(outcome, user, session, :only => %w(id context_type context_id vendor_guid), :methods => [:title]).tap do |hash| json_attributes = %w(id context_type context_id vendor_guid display_name)
api_json(outcome, user, session, :only => json_attributes, :methods => [:title]).tap do |hash|
hash['url'] = api_v1_outcome_path :id => outcome.id hash['url'] = api_v1_outcome_path :id => outcome.id
hash['can_edit'] = outcome.context_id ? hash['can_edit'] = outcome.context_id ?
outcome.context.grants_right?(user, session, :manage_outcomes) : outcome.context.grants_right?(user, session, :manage_outcomes) :

View File

@ -623,6 +623,7 @@ describe "Outcome Groups API", type: :request do
"context_type" => "Account", "context_type" => "Account",
"context_id" => @account.id, "context_id" => @account.id,
"title" => outcome.title, "title" => outcome.title,
"display_name" => nil,
"url" => api_v1_outcome_path(:id => outcome.id), "url" => api_v1_outcome_path(:id => outcome.id),
"can_edit" => true "can_edit" => true
} }
@ -772,6 +773,7 @@ describe "Outcome Groups API", type: :request do
"context_type" => nil, "context_type" => nil,
"context_id" => nil, "context_id" => nil,
"title" => @outcome.title, "title" => @outcome.title,
"display_name" => nil,
"url" => api_v1_outcome_path(:id => @outcome.id), "url" => api_v1_outcome_path(:id => @outcome.id),
"can_edit" => false "can_edit" => false
} }
@ -815,6 +817,7 @@ describe "Outcome Groups API", type: :request do
:id => @group.id.to_s, :id => @group.id.to_s,
:format => 'json' }, :format => 'json' },
{ :title => "My Outcome", { :title => "My Outcome",
:display_name => "Friendly Name",
:description => "Description of my outcome", :description => "Description of my outcome",
:mastery_points => 5, :mastery_points => 5,
:ratings => [ :ratings => [
@ -826,6 +829,7 @@ describe "Outcome Groups API", type: :request do
LearningOutcome.active.count.should == 1 LearningOutcome.active.count.should == 1
@outcome = LearningOutcome.active.first @outcome = LearningOutcome.active.first
@outcome.title.should == "My Outcome" @outcome.title.should == "My Outcome"
@outcome.display_name.should == "Friendly Name"
@outcome.description.should == "Description of my outcome" @outcome.description.should == "Description of my outcome"
@outcome.data[:rubric_criterion].should == { @outcome.data[:rubric_criterion].should == {
:description => 'My Outcome', :description => 'My Outcome',
@ -955,6 +959,7 @@ describe "Outcome Groups API", type: :request do
"vendor_guid" => @outcome.vendor_guid, "vendor_guid" => @outcome.vendor_guid,
"context_type" => nil, "context_type" => nil,
"context_id" => nil, "context_id" => nil,
"display_name" => nil,
"title" => @outcome.title, "title" => @outcome.title,
"url" => api_v1_outcome_path(:id => @outcome.id), "url" => api_v1_outcome_path(:id => @outcome.id),
"can_edit" => false "can_edit" => false

View File

@ -104,6 +104,7 @@ describe "Outcomes API", type: :request do
"context_id" => @account.id, "context_id" => @account.id,
"context_type" => "Account", "context_type" => "Account",
"title" => @outcome.title, "title" => @outcome.title,
"display_name" => nil,
"url" => api_v1_outcome_path(:id => @outcome.id), "url" => api_v1_outcome_path(:id => @outcome.id),
"vendor_guid" => "vendorguid9000", "vendor_guid" => "vendorguid9000",
"can_edit" => true, "can_edit" => true,
@ -134,6 +135,7 @@ describe "Outcomes API", type: :request do
"context_id" => @account.id, "context_id" => @account.id,
"context_type" => "Account", "context_type" => "Account",
"title" => @outcome.title, "title" => @outcome.title,
"display_name" => nil,
"url" => api_v1_outcome_path(:id => @outcome.id), "url" => api_v1_outcome_path(:id => @outcome.id),
"vendor_guid" => "vendorguid9000", "vendor_guid" => "vendorguid9000",
"can_edit" => true, "can_edit" => true,
@ -250,6 +252,7 @@ describe "Outcomes API", type: :request do
"context_type" => "Account", "context_type" => "Account",
"vendor_guid" => "vendorguid9000", "vendor_guid" => "vendorguid9000",
"title" => "New Title", "title" => "New Title",
"display_name" => nil,
"url" => api_v1_outcome_path(:id => @outcome.id), "url" => api_v1_outcome_path(:id => @outcome.id),
"can_edit" => true, "can_edit" => true,
"description" => "New Description" "description" => "New Description"

View File

@ -0,0 +1,37 @@
#
# Copyright (C) 2014 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/>.
require File.expand_path(File.dirname(__FILE__) + '/../../../spec_helper.rb')
class Subject
include Api::V1::Outcome
def api_v1_outcome_path(opts)
"/api/v1/outcome/#{opts.fetch(:id)}"
end
end
describe "Api::V1::Outcome" do
describe "#outcome_json" do
it "includes the display name from the outcome" do
outcome = LearningOutcome.new(display_name: "MyFavoriteOutcome")
subj = Subject.new
result = subj.outcome_json(outcome, nil, nil)
result['display_name'].should == "MyFavoriteOutcome"
end
end
end

View File

@ -153,10 +153,8 @@ def should_create_a_learning_outcome_nested
replace_content(f('.outcomes-content input[name=title]'), outcome_name) replace_content(f('.outcomes-content input[name=title]'), outcome_name)
# submit # submit
driver.execute_script("$('.submit_button').click()") f('.submit_button').click
if !f('.submit_button').nil? wait_for_ajaximations
driver.execute_script("$('.submit_button').click()")
end
refresh_page refresh_page
#select group #select group