Move discussion .coffee files to .js

This commit is contained in:
Andy Armstrong
2016-06-23 10:32:33 -04:00
parent 5b187d6f05
commit b1af59ad99
40 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,218 @@
if Backbone?
class @Content extends Backbone.Model
@contents: {}
@contentInfos: {}
template: -> DiscussionUtil.getTemplate('_content')
actions:
editable: '.admin-edit'
can_reply: '.discussion-reply'
can_delete: '.admin-delete'
can_openclose: '.admin-openclose'
can_report: '.admin-report'
can_vote: '.admin-vote'
urlMappers: {}
urlFor: (name) ->
@urlMappers[name].apply(@)
can: (action) ->
(@get('ability') || {})[action]
# Default implementation
canBeEndorsed: -> false
updateInfo: (info) ->
if info
@set('ability', info.ability)
@set('voted', info.voted)
@set('subscribed', info.subscribed)
addComment: (comment, options) ->
options ||= {}
if not options.silent
thread = @get('thread')
comments_count = parseInt(thread.get('comments_count'))
thread.set('comments_count', comments_count + 1)
@get('children').push comment
model = new Comment $.extend {}, comment, { thread: @get('thread') }
@get('comments').add model
@trigger "comment:add"
model
removeComment: (comment) ->
thread = @get('thread')
comments_count = parseInt(thread.get('comments_count'))
thread.set('comments_count', comments_count - 1 - comment.getCommentsCount())
@trigger "comment:remove"
resetComments: (children) ->
@set 'children', []
@set 'comments', new Comments()
for comment in (children || [])
@addComment comment, { silent: true }
initialize: ->
Content.addContent @id, @
userId = @get('user_id')
if userId?
@set('staff_authored', DiscussionUtil.isStaff(userId))
@set('community_ta_authored', DiscussionUtil.isTA(userId))
else
@set('staff_authored', false)
@set('community_ta_authored', false)
if Content.getInfo(@id)
@updateInfo(Content.getInfo(@id))
@set 'user_url', DiscussionUtil.urlFor('user_profile', userId)
@resetComments(@get('children'))
remove: ->
if @get('type') == 'comment'
@get('thread').removeComment(@)
@get('thread').trigger "comment:remove", @
else
@trigger "thread:remove", @
@addContent: (id, content) -> @contents[id] = content
@getContent: (id) -> @contents[id]
@getInfo: (id) ->
@contentInfos[id]
@loadContentInfos: (infos) ->
for id, info of infos
if @getContent(id)
@getContent(id).updateInfo(info)
$.extend @contentInfos, infos
pinThread: ->
pinned = @get("pinned")
@set("pinned",pinned)
@trigger "change", @
unPinThread: ->
pinned = @get("pinned")
@set("pinned",pinned)
@trigger "change", @
flagAbuse: ->
temp_array = @get("abuse_flaggers")
temp_array.push(window.user.get('id'))
@set("abuse_flaggers",temp_array)
@trigger "change", @
unflagAbuse: ->
@get("abuse_flaggers").pop(window.user.get('id'))
@trigger "change", @
isFlagged: ->
user = DiscussionUtil.getUser()
flaggers = @get("abuse_flaggers")
user and (user.id in flaggers or (DiscussionUtil.isPrivilegedUser(user.id) and flaggers.length > 0))
incrementVote: (increment) ->
newVotes = _.clone(@get("votes"))
newVotes.up_count = newVotes.up_count + increment
@set("votes", newVotes)
vote: ->
@incrementVote(1)
unvote: ->
@incrementVote(-1)
class @Thread extends @Content
urlMappers:
'retrieve' : -> DiscussionUtil.urlFor('retrieve_single_thread', @.get('commentable_id'), @id)
'reply' : -> DiscussionUtil.urlFor('create_comment', @id)
'unvote' : -> DiscussionUtil.urlFor("undo_vote_for_#{@get('type')}", @id)
'upvote' : -> DiscussionUtil.urlFor("upvote_#{@get('type')}", @id)
'downvote' : -> DiscussionUtil.urlFor("downvote_#{@get('type')}", @id)
'close' : -> DiscussionUtil.urlFor('openclose_thread', @id)
'update' : -> DiscussionUtil.urlFor('update_thread', @id)
'_delete' : -> DiscussionUtil.urlFor('delete_thread', @id)
'follow' : -> DiscussionUtil.urlFor('follow_thread', @id)
'unfollow' : -> DiscussionUtil.urlFor('unfollow_thread', @id)
'flagAbuse' : -> DiscussionUtil.urlFor("flagAbuse_#{@get('type')}", @id)
'unFlagAbuse' : -> DiscussionUtil.urlFor("unFlagAbuse_#{@get('type')}", @id)
'pinThread' : -> DiscussionUtil.urlFor("pin_thread", @id)
'unPinThread' : -> DiscussionUtil.urlFor("un_pin_thread", @id)
initialize: ->
@set('thread', @)
super()
comment: ->
@set("comments_count", parseInt(@get("comments_count")) + 1)
follow: ->
@set('subscribed', true)
unfollow: ->
@set('subscribed', false)
display_body: ->
if @has("highlighted_body")
String(@get("highlighted_body")).replace(/<highlight>/g, '<mark>').replace(/<\/highlight>/g, '</mark>')
else
@get("body")
display_title: ->
if @has("highlighted_title")
String(@get("highlighted_title")).replace(/<highlight>/g, '<mark>').replace(/<\/highlight>/g, '</mark>')
else
@get("title")
toJSON: ->
json_attributes = _.clone(@attributes)
_.extend(json_attributes, { title: @display_title(), body: @display_body() })
created_at_date: ->
new Date(@get("created_at"))
created_at_time: ->
new Date(@get("created_at")).getTime()
hasResponses: ->
@get('comments_count') > 0
class @Comment extends @Content
urlMappers:
'reply': -> DiscussionUtil.urlFor('create_sub_comment', @id)
'unvote': -> DiscussionUtil.urlFor("undo_vote_for_#{@get('type')}", @id)
'upvote': -> DiscussionUtil.urlFor("upvote_#{@get('type')}", @id)
'downvote': -> DiscussionUtil.urlFor("downvote_#{@get('type')}", @id)
'endorse': -> DiscussionUtil.urlFor('endorse_comment', @id)
'update': -> DiscussionUtil.urlFor('update_comment', @id)
'_delete': -> DiscussionUtil.urlFor('delete_comment', @id)
'flagAbuse' : -> DiscussionUtil.urlFor("flagAbuse_#{@get('type')}", @id)
'unFlagAbuse' : -> DiscussionUtil.urlFor("unFlagAbuse_#{@get('type')}", @id)
getCommentsCount: ->
count = 0
@get('comments').each (comment) ->
count += comment.getCommentsCount() + 1
count
canBeEndorsed: =>
user_id = window.user.get("id")
user_id && (
DiscussionUtil.isPrivilegedUser(user_id) ||
(@get('thread').get('thread_type') == 'question' && @get('thread').get('user_id') == user_id)
)
class @Comments extends Backbone.Collection
model: Comment
initialize: ->
@bind "add", (item) =>
item.collection = @
find: (id) ->
_.first @where(id: id)

View File

@@ -0,0 +1,131 @@
if Backbone?
class @Discussion extends Backbone.Collection
model: Thread
initialize: (models, options={})->
@pages = options['pages'] || 1
@current_page = 1
@sort_preference = options['sort']
@bind "add", (item) =>
item.discussion = @
@setSortComparator(@sort_preference)
@on "thread:remove", (thread) =>
@remove(thread)
find: (id) ->
_.first @where(id: id)
hasMorePages: ->
@current_page < @pages
setSortComparator: (sortBy) ->
switch sortBy
when 'activity' then @comparator = @sortByDateRecentFirst
when 'votes' then @comparator = @sortByVotes
when 'comments' then @comparator = @sortByComments
addThread: (thread, options) ->
# TODO: Check for existing thread with same ID in a faster way
if not @find(thread.id)
options ||= {}
model = new Thread thread
@add model
model
retrieveAnotherPage: (mode, options={}, sort_options={}, error=null)->
data = { page: @current_page + 1 }
if _.contains(["unread", "unanswered", "flagged"], options.filter)
data[options.filter] = true
switch mode
when 'search'
url = DiscussionUtil.urlFor 'search'
data['text'] = options.search_text
when 'commentables'
url = DiscussionUtil.urlFor 'search'
data['commentable_ids'] = options.commentable_ids
when 'all'
url = DiscussionUtil.urlFor 'threads'
when 'followed'
url = DiscussionUtil.urlFor 'followed_threads', options.user_id
if options['group_id']
data['group_id'] = options['group_id']
data['sort_key'] = sort_options.sort_key || 'activity'
data['sort_order'] = sort_options.sort_order || 'desc'
DiscussionUtil.safeAjax
$elem: @$el
url: url
data: data
dataType: 'json'
success: (response, textStatus) =>
models = @models
new_threads = [new Thread(data) for data in response.discussion_data][0]
new_collection = _.union(models, new_threads)
Content.loadContentInfos(response.annotated_content_info)
@pages = response.num_pages
@current_page = response.page
@reset new_collection
error: error
sortByDate: (thread) ->
#
# The comment client asks each thread for a value by which to sort the collection
# and calls this sort routine regardless of the order returned from the LMS/comments service
# so, this takes advantage of this per-thread value and returns tomorrow's date
# for pinned threads, ensuring that they appear first, (which is the intent of pinned threads)
#
@pinnedThreadsSortComparatorWithDate(thread, true)
sortByDateRecentFirst: (thread) ->
#
# Same as above
# but negative to flip the order (newest first)
#
@pinnedThreadsSortComparatorWithDate(thread, false)
#return String.fromCharCode.apply(String,
# _.map(thread.get("created_at").split(""),
# ((c) -> return 0xffff - c.charChodeAt()))
#)
sortByVotes: (thread1, thread2) ->
thread1_count = parseInt(thread1.get("votes")['up_count'])
thread2_count = parseInt(thread2.get("votes")['up_count'])
@pinnedThreadsSortComparatorWithCount(thread1, thread2, thread1_count, thread2_count)
sortByComments: (thread1, thread2) ->
thread1_count = parseInt(thread1.get("comments_count"))
thread2_count = parseInt(thread2.get("comments_count"))
@pinnedThreadsSortComparatorWithCount(thread1, thread2, thread1_count, thread2_count)
pinnedThreadsSortComparatorWithCount: (thread1, thread2, thread1_count, thread2_count) ->
# if threads are pinned they should be displayed on top.
# Unpinned will be sorted by their property count
if thread1.get('pinned') and not thread2.get('pinned')
-1
else if thread2.get('pinned') and not thread1.get('pinned')
1
else
if thread1_count > thread2_count
-1
else if thread2_count > thread1_count
1
else
if thread1.created_at_time() > thread2.created_at_time()
-1
else
1
pinnedThreadsSortComparatorWithDate: (thread, ascending)->
# if threads are pinned they should be displayed on top.
# Unpinned will be sorted by their last activity date
threadLastActivityAtTime = new Date(thread.get("last_activity_at")).getTime()
if thread.get('pinned')
#use tomorrow's date
today = new Date();
preferredDate = new Date(today.getTime() + (24 * 60 * 60 * 1000) + threadLastActivityAtTime);
else
preferredDate = threadLastActivityAtTime
if ascending
preferredDate
else
-(preferredDate)

View File

@@ -0,0 +1,173 @@
if Backbone?
class @DiscussionModuleView extends Backbone.View
events:
"click .discussion-show": "toggleDiscussion"
"keydown .discussion-show":
(event) -> DiscussionUtil.activateOnSpace(event, @toggleDiscussion)
"click .new-post-btn": "toggleNewPost"
"keydown .new-post-btn":
(event) -> DiscussionUtil.activateOnSpace(event, @toggleNewPost)
"click .discussion-paginator a": "navigateToPage"
page_re: /\?discussion_page=(\d+)/
initialize: (options) ->
@toggleDiscussionBtn = @$(".discussion-show")
# Set the page if it was set in the URL. This is used to allow deep linking to pages
match = @page_re.exec(window.location.href)
@context = options.context or "course" # allowed values are "course" or "standalone"
if match
@page = parseInt(match[1])
else
@page = 1
toggleNewPost: (event) =>
event.preventDefault()
if !@newPostForm
@toggleDiscussion()
@isWaitingOnNewPost = true;
return
if @showed
@newPostForm.slideDown(300)
else
@newPostForm.show().focus()
@toggleDiscussionBtn.addClass('shown')
@toggleDiscussionBtn.find('.button-text').html(gettext("Hide Discussion"))
@$("section.discussion").slideDown()
@showed = true
hideNewPost: =>
@newPostForm.slideUp(300)
hideDiscussion: =>
@$("section.discussion").slideUp()
@toggleDiscussionBtn.removeClass('shown')
@toggleDiscussionBtn.find('.button-text').html(gettext("Show Discussion"))
@showed = false
toggleDiscussion: (event) =>
if @showed
@hideDiscussion()
else
@toggleDiscussionBtn.addClass('shown')
@toggleDiscussionBtn.find('.button-text').html(gettext("Hide Discussion"))
if @retrieved
@$("section.discussion").slideDown()
@showed = true
else
$elem = @toggleDiscussionBtn
@loadPage(
$elem,
=>
@hideDiscussion()
DiscussionUtil.discussionAlert(
gettext("Sorry"),
gettext("We had some trouble loading the discussion. Please try again.")
)
)
loadPage: ($elem, error) =>
discussionId = @$el.data("discussion-id")
url = DiscussionUtil.urlFor('retrieve_discussion', discussionId) + "?page=#{@page}"
DiscussionUtil.safeAjax
$elem: $elem
$loading: $elem
takeFocus: true
url: url
type: "GET"
dataType: 'json'
success: (response, textStatus, jqXHR) => @renderDiscussion($elem, response, textStatus, discussionId)
error: error
renderDiscussion: ($elem, response, textStatus, discussionId) =>
$elem.focus()
user = new DiscussionUser(response.user_info)
window.user = user
DiscussionUtil.setUser(user)
Content.loadContentInfos(response.annotated_content_info)
DiscussionUtil.loadRoles(response.roles)
@course_settings = new DiscussionCourseSettings(response.course_settings)
@discussion = new Discussion()
@discussion.reset(response.discussion_data, {silent: false})
$discussion = _.template($("#inline-discussion-template").html())(
'threads': response.discussion_data,
'discussionId': discussionId
)
if @$('section.discussion').length
@$('section.discussion').replaceWith($discussion)
else
@$el.append($discussion)
@newPostForm = this.$el.find('.new-post-article')
@threadviews = @discussion.map (thread) =>
view = new DiscussionThreadView(
el: @$("article#thread_#{thread.id}"),
model: thread,
mode: "inline",
context: @context,
course_settings: @course_settings,
topicId: discussionId
)
thread.on "thread:thread_type_updated", ->
view.rerender()
view.expand()
return view
_.each @threadviews, (dtv) -> dtv.render()
DiscussionUtil.bulkUpdateContentInfo(window.$$annotated_content_info)
@newPostView = new NewPostView(
el: @newPostForm,
collection: @discussion,
course_settings: @course_settings,
topicId: discussionId,
is_commentable_cohorted: response.is_commentable_cohorted
)
@newPostView.render()
@listenTo( @newPostView, 'newPost:cancel', @hideNewPost )
@discussion.on "add", @addThread
@retrieved = true
@showed = true
@renderPagination(response.num_pages)
if @isWaitingOnNewPost
@newPostForm.show().focus()
addThread: (thread, collection, options) =>
# TODO: When doing pagination, this will need to repaginate. Perhaps just reload page 1?
article = $("<article class='discussion-thread' id='thread_#{thread.id}'></article>")
@$('section.discussion > .threads').prepend(article)
threadView = new DiscussionThreadView(
el: article,
model: thread,
mode: "inline",
context: @context,
course_settings: @course_settings,
topicId: @$el.data("discussion-id")
)
threadView.render()
@threadviews.unshift threadView
renderPagination: (numPages) =>
pageUrl = (number) ->
"?discussion_page=#{number}"
params = DiscussionUtil.getPaginationParams(@page, numPages, pageUrl)
pagination = _.template($("#pagination-template").html())(params)
@$('section.discussion-pagination').html(pagination)
navigateToPage: (event) =>
event.preventDefault()
window.history.pushState({}, window.document.title, event.target.href)
currPage = @page
@page = $(event.target).data('page-number')
@loadPage(
$(event.target),
=>
@page = currPage
DiscussionUtil.discussionAlert(
gettext("Sorry"),
gettext("We had some trouble loading the threads you requested. Please try again.")
)
)

View File

@@ -0,0 +1,90 @@
if Backbone?
class @DiscussionRouter extends Backbone.Router
routes:
"": "allThreads"
":forum_name/threads/:thread_id" : "showThread"
initialize: (options) ->
@discussion = options['discussion']
@course_settings = options['course_settings']
@nav = new DiscussionThreadListView(
collection: @discussion,
el: $(".forum-nav"),
courseSettings: @course_settings
)
@nav.on "thread:selected", @navigateToThread
@nav.on "thread:removed", @navigateToAllThreads
@nav.on "threads:rendered", @setActiveThread
@nav.on "thread:created", @navigateToThread
@nav.render()
@newPost = $('.new-post-article')
@newPostView = new NewPostView(
el: @newPost,
collection: @discussion,
course_settings: @course_settings,
mode: "tab"
)
@newPostView.render()
@listenTo( @newPostView, 'newPost:cancel', @hideNewPost )
$('.new-post-btn').bind "click", @showNewPost
$('.new-post-btn').bind "keydown", (event) => DiscussionUtil.activateOnSpace(event, @showNewPost)
allThreads: ->
@nav.updateSidebar()
@nav.goHome()
setActiveThread: =>
if @thread
@nav.setActiveThread(@thread.get("id"))
else
@nav.goHome
showThread: (forum_name, thread_id) ->
@thread = @discussion.get(thread_id)
@thread.set("unread_comments_count", 0)
@thread.set("read", true)
@setActiveThread()
@showMain()
showMain: =>
if(@main)
@main.cleanup()
@main.undelegateEvents()
unless($(".forum-content").is(":visible"))
$(".forum-content").fadeIn()
if(@newPost.is(":visible"))
@newPost.fadeOut()
@main = new DiscussionThreadView(
el: $(".forum-content"),
model: @thread,
mode: "tab",
course_settings: @course_settings,
)
@main.render()
@main.on "thread:responses:rendered", =>
@nav.updateSidebar()
@thread.on "thread:thread_type_updated", @showMain
navigateToThread: (thread_id) =>
thread = @discussion.get(thread_id)
@navigate("#{thread.get("commentable_id")}/threads/#{thread_id}", trigger: true)
navigateToAllThreads: =>
@navigate("", trigger: true)
showNewPost: (event) =>
$('.forum-content').fadeOut(
duration: 200
complete: =>
@newPost.fadeIn(200).focus()
)
hideNewPost: =>
@newPost.fadeOut(
duration: 200
complete: =>
$('.forum-content').fadeIn(200).find('.thread-wrapper').focus()
)

View File

@@ -0,0 +1,38 @@
if Backbone?
DiscussionApp =
start: (elem)->
# TODO: Perhaps eliminate usage of global variables when possible
DiscussionUtil.loadRolesFromContainer()
element = $(elem)
window.$$course_id = element.data("course-id")
window.courseName = element.data("course-name")
user_info = element.data("user-info")
sort_preference = element.data("sort-preference")
threads = element.data("threads")
thread_pages = element.data("thread-pages")
content_info = element.data("content-info")
user = new DiscussionUser(user_info)
DiscussionUtil.setUser(user)
window.user = user
Content.loadContentInfos(content_info)
discussion = new Discussion(threads, {pages: thread_pages, sort: sort_preference})
course_settings = new DiscussionCourseSettings(element.data("course-settings"))
new DiscussionRouter({discussion: discussion, course_settings: course_settings})
Backbone.history.start({pushState: true, root: "/courses/#{$$course_id}/discussion/forum/"})
DiscussionProfileApp =
start: (elem) ->
# Roles are not included in user profile page, but they are not used for anything
DiscussionUtil.loadRoles({"Moderator": [], "Administrator": [], "Community TA": []})
element = $(elem)
window.$$course_id = element.data("course-id")
threads = element.data("threads")
user_info = element.data("user-info")
window.user = new DiscussionUser(user_info)
page = element.data("page")
numPages = element.data("num-pages")
new DiscussionUserProfileView(el: element, collection: threads, page: page, numPages: numPages)
$ ->
$("section.discussion").each (index, elem) ->
DiscussionApp.start(elem)
$("section.discussion-user-threads").each (index, elem) ->
DiscussionProfileApp.start(elem)

View File

@@ -0,0 +1,2 @@
if Backbone?
class @DiscussionCourseSettings extends Backbone.Model

View File

@@ -0,0 +1,15 @@
if Backbone?
class @DiscussionUser extends Backbone.Model
following: (thread) ->
_.include(@get('subscribed_thread_ids'), thread.id)
voted: (thread) ->
_.include(@get('upvoted_ids'), thread.id)
vote: (thread) ->
@get('upvoted_ids').push(thread.id)
thread.vote()
unvote: (thread) ->
@set('upvoted_ids', _.without(@get('upvoted_ids'), thread.id))
thread.unvote()

View File

@@ -0,0 +1,346 @@
class @DiscussionUtil
@wmdEditors: {}
@getTemplate: (id) ->
$("script##{id}").html()
@setUser: (user) ->
@user = user
@getUser: () ->
@user
@loadRoles: (roles)->
@roleIds = roles
@loadRolesFromContainer: ->
@loadRoles($("#discussion-container").data("roles"))
@isStaff: (user_id) ->
user_id ?= @user?.id
staff = _.union(@roleIds['Moderator'], @roleIds['Administrator'])
_.include(staff, parseInt(user_id))
@isTA: (user_id) ->
user_id ?= @user?.id
ta = _.union(@roleIds['Community TA'])
_.include(ta, parseInt(user_id))
@isPrivilegedUser: (user_id) ->
@isStaff(user_id) || @isTA(user_id)
@bulkUpdateContentInfo: (infos) ->
for id, info of infos
Content.getContent(id).updateInfo(info)
@generateDiscussionLink: (cls, txt, handler) ->
$("<a>").addClass("discussion-link")
.attr("href", "javascript:void(0)")
.addClass(cls).html(txt)
.click -> handler(this)
@urlFor: (name, param, param1, param2) ->
{
follow_discussion : "/courses/#{$$course_id}/discussion/#{param}/follow"
unfollow_discussion : "/courses/#{$$course_id}/discussion/#{param}/unfollow"
create_thread : "/courses/#{$$course_id}/discussion/#{param}/threads/create"
update_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/update"
create_comment : "/courses/#{$$course_id}/discussion/threads/#{param}/reply"
delete_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/delete"
flagAbuse_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/flagAbuse"
unFlagAbuse_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/unFlagAbuse"
flagAbuse_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/flagAbuse"
unFlagAbuse_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/unFlagAbuse"
upvote_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/upvote"
downvote_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/downvote"
pin_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/pin"
un_pin_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/unpin"
undo_vote_for_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/unvote"
follow_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/follow"
unfollow_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/unfollow"
update_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/update"
endorse_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/endorse"
create_sub_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/reply"
delete_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/delete"
upvote_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/upvote"
downvote_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/downvote"
undo_vote_for_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/unvote"
upload : "/courses/#{$$course_id}/discussion/upload"
users : "/courses/#{$$course_id}/discussion/users"
search : "/courses/#{$$course_id}/discussion/forum/search"
retrieve_discussion : "/courses/#{$$course_id}/discussion/forum/#{param}/inline"
retrieve_single_thread : "/courses/#{$$course_id}/discussion/forum/#{param}/threads/#{param1}"
openclose_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/close"
permanent_link_thread : "/courses/#{$$course_id}/discussion/forum/#{param}/threads/#{param1}"
permanent_link_comment : "/courses/#{$$course_id}/discussion/forum/#{param}/threads/#{param1}##{param2}"
user_profile : "/courses/#{$$course_id}/discussion/forum/users/#{param}"
followed_threads : "/courses/#{$$course_id}/discussion/forum/users/#{param}/followed"
threads : "/courses/#{$$course_id}/discussion/forum"
"enable_notifications" : "/notification_prefs/enable/"
"disable_notifications" : "/notification_prefs/disable/"
"notifications_status" : "/notification_prefs/status/"
}[name]
@ignoreEnterKey: (event) =>
if event.which == 13
event.preventDefault()
@activateOnSpace: (event, func) ->
if event.which == 32
event.preventDefault()
func(event)
@makeFocusTrap: (elem) ->
elem.keydown(
(event) ->
if event.which == 9 # Tab
event.preventDefault()
)
@showLoadingIndicator: (element, takeFocus) ->
@$_loading = $("<div class='loading-animation' tabindex='0'><span class='sr'>" + gettext("Loading content") + "</span></div>")
element.after(@$_loading)
if takeFocus
@makeFocusTrap(@$_loading)
@$_loading.focus()
@hideLoadingIndicator: () ->
@$_loading.remove()
@discussionAlert: (header, body) ->
if $("#discussion-alert").length == 0
alertDiv = $("<div class='modal' role='alertdialog' id='discussion-alert' aria-describedby='discussion-alert-message'/>").css("display", "none")
alertDiv.html(
"<div class='inner-wrapper discussion-alert-wrapper'>" +
" <button class='close-modal dismiss' title='" + gettext("Close") + "'><span class='icon fa fa-times' aria-hidden='true'></span></button>" +
" <header><h2/><hr/></header>" +
" <p id='discussion-alert-message'/>" +
" <hr/>" +
" <button class='dismiss'>" + gettext("OK") + "</button>" +
"</div>"
)
@makeFocusTrap(alertDiv.find("button"))
alertTrigger = $("<a href='#discussion-alert' id='discussion-alert-trigger'/>").css("display", "none")
alertTrigger.leanModal({closeButton: "#discussion-alert .dismiss", overlay: 1, top: 200})
$("body").append(alertDiv).append(alertTrigger)
$("#discussion-alert header h2").html(header)
$("#discussion-alert p").html(body)
$("#discussion-alert-trigger").click()
$("#discussion-alert button").focus()
@safeAjax: (params) ->
$elem = params.$elem
if $elem and $elem.attr("disabled")
deferred = $.Deferred()
deferred.reject()
return deferred.promise()
params["url"] = URI(params["url"]).addSearch ajax: 1
params["beforeSend"] = =>
if $elem
$elem.attr("disabled", "disabled")
if params["$loading"]
if params["loadingCallback"]?
params["loadingCallback"].apply(params["$loading"])
else
@showLoadingIndicator($(params["$loading"]), params["takeFocus"])
if !params["error"]
params["error"] = =>
@discussionAlert(
gettext("Sorry"),
gettext("We had some trouble processing your request. Please ensure you have copied any unsaved work and then reload the page.")
)
request = $.ajax(params).always =>
if $elem
$elem.removeAttr("disabled")
if params["$loading"]
if params["loadedCallback"]?
params["loadedCallback"].apply(params["$loading"])
else
@hideLoadingIndicator()
return request
@updateWithUndo: (model, updates, safeAjaxParams, errorMsg) ->
if errorMsg
safeAjaxParams.error = => @discussionAlert(gettext("Sorry"), errorMsg)
undo = _.pick(model.attributes, _.keys(updates))
model.set(updates)
@safeAjax(safeAjaxParams).fail(() -> model.set(undo))
@bindLocalEvents: ($local, eventsHandler) ->
for eventSelector, handler of eventsHandler
[event, selector] = eventSelector.split(' ')
$local(selector).unbind(event)[event] handler
@formErrorHandler: (errorsField) ->
(xhr, textStatus, error) ->
makeErrorElem = (message) ->
$("<li>").addClass("post-error").html(message)
errorsField.empty().show()
if xhr.status == 400
response = JSON.parse(xhr.responseText)
if response.errors? and response.errors.length > 0
for error in response.errors
errorsField.append(makeErrorElem(error))
else
errorsField.append(
makeErrorElem(
gettext("We had some trouble processing your request. Please try again.")
)
)
@clearFormErrors: (errorsField) ->
errorsField.empty()
@postMathJaxProcessor: (text) ->
RE_INLINEMATH = /^\$([^\$]*)\$/g
RE_DISPLAYMATH = /^\$\$([^\$]*)\$\$/g
@processEachMathAndCode text, (s, type) ->
if type == 'display'
s.replace RE_DISPLAYMATH, ($0, $1) ->
"\\[" + $1 + "\\]"
else if type == 'inline'
s.replace RE_INLINEMATH, ($0, $1) ->
"\\(" + $1 + "\\)"
else
s
@makeWmdEditor: ($content, $local, cls_identifier) ->
elem = $local(".#{cls_identifier}")
placeholder = elem.data('placeholder')
id = elem.attr("data-id") # use attr instead of data because we want to avoid type coercion
appended_id = "-#{cls_identifier}-#{id}"
imageUploadUrl = @urlFor('upload')
_processor = (_this) ->
(text) -> _this.postMathJaxProcessor(text)
editor = Markdown.makeWmdEditor elem, appended_id, imageUploadUrl, _processor(@)
@wmdEditors["#{cls_identifier}-#{id}"] = editor
if placeholder?
elem.find("#wmd-input#{appended_id}").attr('placeholder', placeholder)
editor
@getWmdEditor: ($content, $local, cls_identifier) ->
elem = $local(".#{cls_identifier}")
id = elem.attr("data-id") # use attr instead of data because we want to avoid type coercion
@wmdEditors["#{cls_identifier}-#{id}"]
@getWmdInput: ($content, $local, cls_identifier) ->
elem = $local(".#{cls_identifier}")
id = elem.attr("data-id") # use attr instead of data because we want to avoid type coercion
$local("#wmd-input-#{cls_identifier}-#{id}")
@getWmdContent: ($content, $local, cls_identifier) ->
@getWmdInput($content, $local, cls_identifier).val()
@setWmdContent: ($content, $local, cls_identifier, text) ->
@getWmdInput($content, $local, cls_identifier).val(text)
@getWmdEditor($content, $local, cls_identifier).refreshPreview()
@processEachMathAndCode: (text, processor) ->
codeArchive = []
RE_DISPLAYMATH = /^([^\$]*?)\$\$([^\$]*?)\$\$(.*)$/m
RE_INLINEMATH = /^([^\$]*?)\$([^\$]+?)\$(.*)$/m
ESCAPED_DOLLAR = '@@ESCAPED_D@@'
ESCAPED_BACKSLASH = '@@ESCAPED_B@@'
processedText = ""
$div = $("<div>").html(text)
$div.find("code").each (index, code) ->
codeArchive.push $(code).html()
$(code).html(codeArchive.length - 1)
text = $div.html()
text = text.replace /\\\$/g, ESCAPED_DOLLAR
while true
if RE_INLINEMATH.test(text)
text = text.replace RE_INLINEMATH, ($0, $1, $2, $3) ->
processedText += $1 + processor("$" + $2 + "$", 'inline')
$3
else if RE_DISPLAYMATH.test(text)
text = text.replace RE_DISPLAYMATH, ($0, $1, $2, $3) ->
#processedText += $1 + processor("$$" + $2 + "$$", 'display')
#bug fix, ordering is off
processedText = processor("$$" + $2 + "$$", 'display') + processedText
processedText = $1 + processedText
$3
else
processedText += text
break
text = processedText
text = text.replace(new RegExp(ESCAPED_DOLLAR, 'g'), '\\$')
text = text.replace /\\\\\\\\/g, ESCAPED_BACKSLASH
text = text.replace /\\begin\{([a-z]*\*?)\}([\s\S]*?)\\end\{\1\}/img, ($0, $1, $2) ->
processor("\\begin{#{$1}}" + $2 + "\\end{#{$1}}")
text = text.replace(new RegExp(ESCAPED_BACKSLASH, 'g'), '\\\\\\\\')
$div = $("<div>").html(text)
cnt = 0
$div.find("code").each (index, code) ->
$(code).html(processor(codeArchive[cnt], 'code'))
cnt += 1
text = $div.html()
text
@unescapeHighlightTag: (text) ->
text.replace(/\&lt\;highlight\&gt\;/g, "<span class='search-highlight'>")
.replace(/\&lt\;\/highlight\&gt\;/g, "</span>")
@stripHighlight: (text) ->
text.replace(/\&(amp\;)?lt\;highlight\&(amp\;)?gt\;/g, "")
.replace(/\&(amp\;)?lt\;\/highlight\&(amp\;)?gt\;/g, "")
@stripLatexHighlight: (text) ->
@processEachMathAndCode text, @stripHighlight
@markdownWithHighlight: (text) ->
text = text.replace(/^\&gt\;/gm, ">")
converter = Markdown.getMathCompatibleConverter()
text = @unescapeHighlightTag @stripLatexHighlight converter.makeHtml text
return text.replace(/^>/gm,"&gt;")
@abbreviateString: (text, minLength) ->
# Abbreviates a string to at least minLength characters, stopping at word boundaries
if text.length<minLength
return text
else
while minLength < text.length && text[minLength] != ' '
minLength++
return text.substr(0, minLength) + gettext('…')
@abbreviateHTML: (html, minLength) ->
# Abbreviates the html to at least minLength characters, stopping at word boundaries
truncated_text = jQuery.truncate(html, {length: minLength, noBreaks: true, ellipsis: gettext('…')})
$result = $("<div>" + truncated_text + "</div>")
imagesToReplace = $result.find("img:not(:first)")
if imagesToReplace.length > 0
$result.append("<p><em>Some images in this post have been omitted</em></p>")
imagesToReplace.replaceWith("<em>image omitted</em>")
$result.html()
@getPaginationParams: (curPage, numPages, pageUrlFunc) =>
delta = 2
minPage = Math.max(curPage - delta, 1)
maxPage = Math.min(curPage + delta, numPages)
pageInfo = (pageNum) -> {number: pageNum, url: pageUrlFunc(pageNum)}
params =
page: curPage
lowPages: _.range(minPage, curPage).map(pageInfo)
highPages: _.range(curPage+1, maxPage+1).map(pageInfo)
previous: if curPage > 1 then pageInfo(curPage - 1) else null
next: if curPage < numPages then pageInfo(curPage + 1) else null
leftdots: minPage > 2
rightdots: maxPage < numPages-1
first: if minPage > 1 then pageInfo(1) else null
last: if maxPage < numPages then pageInfo(numPages) else null

View File

@@ -0,0 +1,303 @@
if Backbone?
class @DiscussionContentView extends Backbone.View
events:
"click .discussion-flag-abuse": "toggleFlagAbuse"
"keydown .discussion-flag-abuse":
(event) -> DiscussionUtil.activateOnSpace(event, @toggleFlagAbuse)
attrRenderer:
ability: (ability) ->
for action, selector of @abilityRenderer
if not ability[action]
selector.disable.apply(@)
else
selector.enable.apply(@)
abilityRenderer:
editable:
enable: -> @$(".action-edit").closest(".actions-item").removeClass("is-hidden")
disable: -> @$(".action-edit").closest(".actions-item").addClass("is-hidden")
can_delete:
enable: -> @$(".action-delete").closest(".actions-item").removeClass("is-hidden")
disable: -> @$(".action-delete").closest(".actions-item").addClass("is-hidden")
can_openclose:
enable: ->
_.each(
[".action-close", ".action-pin"],
(selector) => @$(selector).closest(".actions-item").removeClass("is-hidden")
)
disable: ->
_.each(
[".action-close", ".action-pin"],
(selector) => @$(selector).closest(".actions-item").addClass("is-hidden")
)
can_report:
enable: -> @$(".action-report").closest(".actions-item").removeClass("is-hidden")
disable: -> @$(".action-report").closest(".actions-item").addClass("is-hidden")
can_vote:
enable: -> @$(".action-vote").closest(".actions-item").removeClass("is-hidden")
disable: -> @$(".action-vote").closest(".actions-item").addClass("is-hidden")
renderPartialAttrs: ->
for attr, value of @model.changedAttributes()
if @attrRenderer[attr]
@attrRenderer[attr].apply(@, [value])
renderAttrs: ->
for attr, value of @model.attributes
if @attrRenderer[attr]
@attrRenderer[attr].apply(@, [value])
makeWmdEditor: (cls_identifier) =>
if not @$el.find(".wmd-panel").length
DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), cls_identifier
getWmdEditor: (cls_identifier) =>
DiscussionUtil.getWmdEditor @$el, $.proxy(@$, @), cls_identifier
getWmdContent: (cls_identifier) =>
DiscussionUtil.getWmdContent @$el, $.proxy(@$, @), cls_identifier
setWmdContent: (cls_identifier, text) =>
DiscussionUtil.setWmdContent @$el, $.proxy(@$, @), cls_identifier, text
initialize: ->
@model.bind('change', @renderPartialAttrs, @)
@listenTo(@model, "change:endorsed", =>
if @model instanceof Comment
@trigger("comment:endorse")
)
class @DiscussionContentShowView extends DiscussionContentView
events:
_.reduce(
[
[".action-follow", "toggleFollow"],
[".action-answer", "toggleEndorse"],
[".action-endorse", "toggleEndorse"],
[".action-vote", "toggleVote"],
[".action-more", "toggleSecondaryActions"],
[".action-pin", "togglePin"],
[".action-edit", "edit"],
[".action-delete", "_delete"],
[".action-report", "toggleReport"],
[".action-close", "toggleClose"],
],
(obj, event) =>
selector = event[0]
funcName = event[1]
obj["click #{selector}"] = (event) -> @[funcName](event)
obj["keydown #{selector}"] = (event) -> DiscussionUtil.activateOnSpace(event, @[funcName])
obj
,
{}
)
updateButtonState: (selector, checked) =>
$button = @$(selector)
$button.toggleClass("is-checked", checked)
$button.attr("aria-checked", checked)
attrRenderer: $.extend({}, DiscussionContentView.prototype.attrRenderer, {
subscribed: (subscribed) ->
@updateButtonState(".action-follow", subscribed)
endorsed: (endorsed) ->
selector = if @model.get("thread").get("thread_type") == "question" then ".action-answer" else ".action-endorse"
@updateButtonState(selector, endorsed)
$button = @$(selector)
$button.closest(".actions-item").toggleClass("is-hidden", not @model.canBeEndorsed())
$button.toggleClass("is-checked", endorsed)
votes: (votes) ->
selector = ".action-vote"
@updateButtonState(selector, window.user.voted(@model))
button = @$el.find(selector)
numVotes = votes.up_count
button.find(".js-sr-vote-count").html(
interpolate(
ngettext("there is currently %(numVotes)s vote", "there are currently %(numVotes)s votes", numVotes),
{numVotes: numVotes},
true
)
)
votesHtml = interpolate(
ngettext("%(numVotes)s Vote", "%(numVotes)s Votes", numVotes),
{numVotes: numVotes},
true
)
button.find(".vote-count").html(votesHtml)
@$el.find('.display-vote .vote-count').html(votesHtml)
pinned: (pinned) ->
@updateButtonState(".action-pin", pinned)
@$(".post-label-pinned").toggleClass("is-hidden", not pinned)
abuse_flaggers: (abuse_flaggers) ->
flagged = @model.isFlagged()
@updateButtonState(".action-report", flagged)
@$(".post-label-reported").toggleClass("is-hidden", not flagged)
closed: (closed) ->
@updateButtonState(".action-close", closed)
@$(".post-label-closed").toggleClass("is-hidden", not closed)
@$(".display-vote").toggle(closed)
})
toggleSecondaryActions: (event) =>
event.preventDefault()
event.stopPropagation()
@secondaryActionsExpanded = !@secondaryActionsExpanded
@$(".action-more").toggleClass("is-expanded", @secondaryActionsExpanded)
@$(".actions-dropdown").
toggleClass("is-expanded", @secondaryActionsExpanded).
attr("aria-expanded", @secondaryActionsExpanded)
if @secondaryActionsExpanded
if event.type == "keydown"
@$(".action-list-item:first").focus()
$("body").on("click", @toggleSecondaryActions)
$("body").on("keydown", @handleSecondaryActionEscape)
@$(".action-list-item").on("blur", @handleSecondaryActionBlur)
else
$("body").off("click", @toggleSecondaryActions)
$("body").off("keydown", @handleSecondaryActionEscape)
@$(".action-list-item").off("blur", @handleSecondaryActionBlur)
handleSecondaryActionEscape: (event) =>
if event.keyCode == 27 # Esc
@toggleSecondaryActions(event)
@$(".action-more").focus()
handleSecondaryActionBlur: (event) =>
setTimeout(
=>
if @secondaryActionsExpanded && @$(".actions-dropdown :focus").length == 0
@toggleSecondaryActions(event)
,
10
)
toggleFollow: (event) =>
event.preventDefault()
is_subscribing = not @model.get("subscribed")
url = @model.urlFor(if is_subscribing then "follow" else "unfollow")
if is_subscribing
msg = gettext("We had some trouble subscribing you to this thread. Please try again.")
else
msg = gettext("We had some trouble unsubscribing you from this thread. Please try again.")
DiscussionUtil.updateWithUndo(
@model,
{"subscribed": is_subscribing},
{url: url, type: "POST", $elem: $(event.currentTarget)},
msg
)
toggleEndorse: (event) =>
event.preventDefault()
is_endorsing = not @model.get("endorsed")
url = @model.urlFor("endorse")
updates =
endorsed: is_endorsing
endorsement: if is_endorsing then {username: DiscussionUtil.getUser().get("username"), user_id: DiscussionUtil.getUser().id, time: new Date().toISOString()} else null
if @model.get('thread').get('thread_type') == 'question'
if is_endorsing
msg = gettext("We had some trouble marking this response as an answer. Please try again.")
else
msg = gettext("We had some trouble removing this response as an answer. Please try again.")
else
if is_endorsing
msg = gettext("We had some trouble marking this response endorsed. Please try again.")
else
msg = gettext("We had some trouble removing this endorsement. Please try again.")
beforeFunc = () => @trigger("comment:endorse")
DiscussionUtil.updateWithUndo(
@model,
updates,
{url: url, type: "POST", data: {endorsed: is_endorsing}, beforeSend: beforeFunc, $elem: $(event.currentTarget)},
msg
).always(@trigger("comment:endorse")) # ensures UI components get updated to the correct state when ajax completes
toggleVote: (event) =>
event.preventDefault()
user = DiscussionUtil.getUser()
is_voting = not user.voted(@model)
url = @model.urlFor(if is_voting then "upvote" else "unvote")
updates =
upvoted_ids: (if is_voting then _.union else _.difference)(user.get('upvoted_ids'), [@model.id])
DiscussionUtil.updateWithUndo(
user,
updates,
{url: url, type: "POST", $elem: $(event.currentTarget)},
gettext("We had some trouble saving your vote. Please try again.")
).done(() => if is_voting then @model.vote() else @model.unvote())
togglePin: (event) =>
event.preventDefault()
is_pinning = not @model.get("pinned")
url = @model.urlFor(if is_pinning then "pinThread" else "unPinThread")
if is_pinning
msg = gettext("We had some trouble pinning this thread. Please try again.")
else
msg = gettext("We had some trouble unpinning this thread. Please try again.")
DiscussionUtil.updateWithUndo(
@model,
{pinned: is_pinning},
{url: url, type: "POST", $elem: $(event.currentTarget)},
msg
)
toggleReport: (event) =>
event.preventDefault()
if @model.isFlagged()
is_flagging = false
msg = gettext("We had some trouble removing your flag on this post. Please try again.")
else
is_flagging = true
msg = gettext("We had some trouble reporting this post. Please try again.")
url = @model.urlFor(if is_flagging then "flagAbuse" else "unFlagAbuse")
updates =
abuse_flaggers: (if is_flagging then _.union else _.difference)(@model.get("abuse_flaggers"), [DiscussionUtil.getUser().id])
DiscussionUtil.updateWithUndo(
@model,
updates,
{url: url, type: "POST", $elem: $(event.currentTarget)},
msg
)
toggleClose: (event) =>
event.preventDefault()
is_closing = not @model.get('closed')
if is_closing
msg = gettext("We had some trouble closing this thread. Please try again.")
else
msg = gettext("We had some trouble reopening this thread. Please try again.")
updates = {closed: is_closing}
DiscussionUtil.updateWithUndo(
@model,
updates,
{url: @model.urlFor("close"), type: "POST", data: updates, $elem: $(event.currentTarget)},
msg
)
getAuthorDisplay: ->
_.template($("#post-user-display-template").html())(
username: @model.get('username') || null
user_url: @model.get('user_url')
is_community_ta: @model.get('community_ta_authored')
is_staff: @model.get('staff_authored')
)
getEndorserDisplay: ->
endorsement = @model.get('endorsement')
if endorsement and endorsement.username
_.template($("#post-user-display-template").html())(
username: endorsement.username
user_url: DiscussionUtil.urlFor('user_profile', endorsement.user_id)
is_community_ta: DiscussionUtil.isTA(endorsement.user_id)
is_staff: DiscussionUtil.isStaff(endorsement.user_id)
)
else
null

View File

@@ -0,0 +1,112 @@
(function(Backbone) {
'use strict';
if (Backbone) {
this.DiscussionThreadEditView = Backbone.View.extend({
tagName: 'form',
events: {
'submit': 'updateHandler',
'click .post-cancel': 'cancelHandler'
},
attributes: {
'class': 'discussion-post edit-post-form'
},
initialize: function(options) {
this.container = options.container || $('.thread-content-wrapper');
this.mode = options.mode || 'inline';
this.course_settings = options.course_settings;
this.threadType = this.model.get('thread_type');
this.topicId = this.model.get('commentable_id');
this.context = options.context || 'course';
_.bindAll(this, 'updateHandler', 'cancelHandler');
return this;
},
render: function() {
var threadTypeTemplate,
formId = _.uniqueId("form-");
this.template = _.template($('#thread-edit-template').html());
this.$el.html(this.template(this.model.toJSON())).appendTo(this.container);
this.submitBtn = this.$('.post-update');
threadTypeTemplate = _.template($("#thread-type-template").html());
this.addField(threadTypeTemplate({form_id: formId}));
this.$("#" + formId + "-post-type-" + this.threadType).attr('checked', true);
// Only allow the topic field for course threads, as standalone threads
// cannot be moved.
if (this.context === 'course') {
this.topicView = new DiscussionTopicMenuView({
topicId: this.topicId,
course_settings: this.course_settings
});
this.addField(this.topicView.render());
}
DiscussionUtil.makeWmdEditor(this.$el, $.proxy(this.$, this), 'edit-post-body');
return this;
},
addField: function(fieldView) {
this.$('.forum-edit-post-form-wrapper').append(fieldView);
return this;
},
isTabMode: function () {
return this.mode === 'tab';
},
save: function() {
var title = this.$('.edit-post-title').val(),
threadType = this.$(".post-type-input:checked").val(),
body = this.$('.edit-post-body textarea').val(),
postData = {
title: title,
thread_type: threadType,
body: body
};
if (this.topicView) {
postData.commentable_id = this.topicView.getCurrentTopicId();
}
return DiscussionUtil.safeAjax({
$elem: this.submitBtn,
$loading: this.submitBtn,
url: DiscussionUtil.urlFor('update_thread', this.model.id),
type: 'POST',
dataType: 'json',
data: postData,
error: DiscussionUtil.formErrorHandler(this.$('.post-errors')),
success: function() {
this.$('.edit-post-title').val('').attr('prev-text', '');
this.$('.edit-post-body textarea').val('').attr('prev-text', '');
this.$('.wmd-preview p').html('');
if (this.topicView) {
postData.courseware_title = this.topicView.getFullTopicName();
}
this.model.set(postData).unset('abbreviatedBody');
this.trigger('thread:updated');
if (this.threadType !== threadType) {
this.model.set("thread_type", threadType)
this.model.trigger('thread:thread_type_updated');
this.trigger('comment:endorse');
}
}.bind(this)
});
},
updateHandler: function(event) {
event.preventDefault();
// this event is for the moment triggered and used nowhere.
this.trigger('thread:update', event);
this.save();
return this;
},
cancelHandler: function(event) {
event.preventDefault();
this.trigger("thread:cancel_edit", event);
this.remove();
return this;
}
});
}
}).call(this, Backbone);

View File

@@ -0,0 +1,527 @@
if Backbone?
class @DiscussionThreadListView extends Backbone.View
events:
"click .forum-nav-browse": "toggleBrowseMenu"
"keypress .forum-nav-browse-filter-input": (event) => DiscussionUtil.ignoreEnterKey(event)
"keyup .forum-nav-browse-filter-input": "filterTopics"
"click .forum-nav-browse-menu-wrapper": "ignoreClick"
"click .forum-nav-browse-title": "selectTopicHandler"
"keydown .forum-nav-search-input": "performSearch"
"click .fa-search": "performSearch"
"change .forum-nav-sort-control": "sortThreads"
"click .forum-nav-thread-link": "threadSelected"
"click .forum-nav-load-more-link": "loadMorePages"
"change .forum-nav-filter-main-control": "chooseFilter"
"change .forum-nav-filter-cohort-control": "chooseCohort"
initialize: (options) ->
@courseSettings = options.courseSettings
@displayedCollection = new Discussion(@collection.models, pages: @collection.pages)
@collection.on "change", @reloadDisplayedCollection
@discussionIds=""
@collection.on "reset", (discussion) =>
board = $(".current-board").html()
@displayedCollection.current_page = discussion.current_page
@displayedCollection.pages = discussion.pages
@displayedCollection.reset discussion.models
# TODO: filter correctly
# target = _.filter($("a.topic:contains('#{board}')"), (el) -> el.innerText == "General" || el.innerHTML == "General")
# if target.length > 0
# @filterTopic($.Event("filter", {'target': target[0]}))
@collection.on "add", @addAndSelectThread
@sidebar_padding = 10
@boardName
@template = _.template($("#thread-list-template").html())
@current_search = ""
@mode = 'all'
@searchAlertCollection = new Backbone.Collection([], {model: Backbone.Model})
@searchAlertCollection.on "add", (searchAlert) =>
content = _.template(
$("#search-alert-template").html())(
{'message': searchAlert.attributes.message, 'cid': searchAlert.cid}
)
@$(".search-alerts").append(content)
@$("#search-alert-" + searchAlert.cid + " a.dismiss").bind "click", searchAlert, (event) =>
@removeSearchAlert(event.data.cid)
@searchAlertCollection.on "remove", (searchAlert) =>
@$("#search-alert-" + searchAlert.cid).remove()
@searchAlertCollection.on "reset", =>
@$(".search-alerts").empty()
addSearchAlert: (message) =>
m = new Backbone.Model({"message": message})
@searchAlertCollection.add(m)
m
removeSearchAlert: (searchAlert) =>
@searchAlertCollection.remove(searchAlert)
clearSearchAlerts: =>
@searchAlertCollection.reset()
reloadDisplayedCollection: (thread) =>
@clearSearchAlerts()
thread_id = thread.get('id')
content = @renderThread(thread)
current_el = @$(".forum-nav-thread[data-id=#{thread_id}]")
active = current_el.has(".forum-nav-thread-link.is-active").length != 0
current_el.replaceWith(content)
@showMetadataAccordingToSort()
if active
@setActiveThread(thread_id)
#TODO fix this entire chain of events
addAndSelectThread: (thread) =>
commentable_id = thread.get("commentable_id")
menuItem = @$(".forum-nav-browse-menu-item[data-discussion-id]").filter(-> $(this).data("discussion-id") == commentable_id)
@setCurrentTopicDisplay(@getPathText(menuItem))
@retrieveDiscussion commentable_id, =>
@trigger "thread:created", thread.get('id')
updateSidebar: =>
scrollTop = $(window).scrollTop();
windowHeight = $(window).height();
discussionBody = $(".discussion-column")
discussionsBodyTop = if discussionBody[0] then discussionBody.offset().top
discussionsBodyBottom = discussionsBodyTop + discussionBody.outerHeight()
sidebar = $(".forum-nav")
if scrollTop > discussionsBodyTop - @sidebar_padding
sidebar.css('top', scrollTop - discussionsBodyTop + @sidebar_padding);
else
sidebar.css('top', '0');
sidebarHeight = windowHeight - Math.max(discussionsBodyTop - scrollTop, @sidebar_padding)
topOffset = scrollTop + windowHeight
discussionBottomOffset = discussionsBodyBottom + @sidebar_padding
amount = Math.max(topOffset - discussionBottomOffset, 0)
sidebarHeight = sidebarHeight - @sidebar_padding - amount
sidebarHeight = Math.min(sidebarHeight + 1, discussionBody.outerHeight())
sidebar.css 'height', sidebarHeight
headerHeight = @$(".forum-nav-header").outerHeight()
refineBarHeight = @$(".forum-nav-refine-bar").outerHeight()
browseFilterHeight = @$(".forum-nav-browse-filter").outerHeight()
@$('.forum-nav-thread-list').css('height', (sidebarHeight - headerHeight - refineBarHeight - 2) + 'px')
@$('.forum-nav-browse-menu').css('height', (sidebarHeight - headerHeight - browseFilterHeight - 2) + 'px')
# Because we want the behavior that when the body is clicked the menu is
# closed, we need to stop propagation of a click in any part of the menu
# that is not a link.
ignoreClick: (event) ->
event.stopPropagation()
render: ->
@timer = 0
@$el.html(
@template({
isCohorted: @courseSettings.get("is_cohorted"),
isPrivilegedUser: DiscussionUtil.isPrivilegedUser()
})
)
@$(".forum-nav-sort-control option").removeProp("selected")
@$(".forum-nav-sort-control option[value=#{@collection.sort_preference}]").prop("selected", true)
$(window).bind "load scroll resize", @updateSidebar
@displayedCollection.on "reset", @renderThreads
@displayedCollection.on "thread:remove", @renderThreads
@displayedCollection.on "change:commentable_id", (model, commentable_id) =>
@retrieveDiscussions @discussionIds.split(",") if @mode is "commentables"
@renderThreads()
@
renderThreads: =>
@$(".forum-nav-thread-list").html("")
rendered = $("<div></div>")
for thread in @displayedCollection.models
content = @renderThread(thread)
rendered.append content
@$(".forum-nav-thread-list").html(rendered.html())
@showMetadataAccordingToSort()
@renderMorePages()
@updateSidebar()
@trigger "threads:rendered"
showMetadataAccordingToSort: () =>
# Ensure that threads display metadata appropriate for the current sort
voteCounts = @$(".forum-nav-thread-votes-count")
commentCounts = @$(".forum-nav-thread-comments-count")
voteCounts.hide()
commentCounts.hide()
switch @$(".forum-nav-sort-control").val()
when "activity", "comments"
commentCounts.show()
when "votes"
voteCounts.show()
renderMorePages: ->
if @displayedCollection.hasMorePages()
@$(".forum-nav-thread-list").append("<li class='forum-nav-load-more'><a href='#' class='forum-nav-load-more-link'>" + gettext("Load more") + "</a></li>")
getLoadingContent: (srText) ->
return '<div class="forum-nav-loading" tabindex="0"><span class="icon fa fa-spinner fa-spin"/><span class="sr" role="alert">' + srText + '</span></div>'
loadMorePages: (event) =>
if event
event.preventDefault()
loadMoreElem = @$(".forum-nav-load-more")
loadMoreElem.html(@getLoadingContent(gettext("Loading more threads")))
loadingElem = loadMoreElem.find(".forum-nav-loading")
DiscussionUtil.makeFocusTrap(loadingElem)
loadingElem.focus()
options = {filter: @filter}
switch @mode
when 'search'
options.search_text = @current_search
if @group_id
options.group_id = @group_id
when 'followed'
options.user_id = window.user.id
when 'commentables'
options.commentable_ids = @discussionIds
if @group_id
options.group_id = @group_id
when 'all'
if @group_id
options.group_id = @group_id
lastThread = @collection.last()?.get('id')
if lastThread
# Pagination; focus the first thread after what was previously the last thread
@once("threads:rendered", ->
$(".forum-nav-thread[data-id='#{lastThread}'] + .forum-nav-thread .forum-nav-thread-link").focus()
)
else
# Totally refreshing the list (e.g. from clicking a sort button); focus the first thread
@once("threads:rendered", ->
$(".forum-nav-thread-link").first()?.focus()
)
error = =>
@renderThreads()
DiscussionUtil.discussionAlert(gettext("Sorry"), gettext("We had some trouble loading more threads. Please try again."))
@collection.retrieveAnotherPage(@mode, options, {sort_key: @$(".forum-nav-sort-control").val()}, error)
renderThread: (thread) =>
content = $(_.template($("#thread-list-item-template").html())(thread.toJSON()))
unreadCount = thread.get('unread_comments_count') + (if thread.get("read") then 0 else 1)
if unreadCount > 0
content.find('.forum-nav-thread-comments-count').attr(
"data-tooltip",
interpolate(
ngettext('%(unread_count)s new comment', '%(unread_count)s new comments', unreadCount),
{unread_count: unreadCount},
true
)
)
content
threadSelected: (e) =>
# Use .attr('data-id') rather than .data('id') because .data does type
# coercion. Usually, this is fine, but when Mongo gives an object id with
# no letters, it casts it to a Number.
thread_id = $(e.target).closest(".forum-nav-thread").attr("data-id")
@setActiveThread(thread_id)
@trigger("thread:selected", thread_id) # This triggers a callback in the DiscussionRouter which calls the line above...
false
threadRemoved: (thread_id) =>
@trigger("thread:removed", thread_id)
setActiveThread: (thread_id) ->
@$(".forum-nav-thread-link").find(".sr").remove()
@$(".forum-nav-thread[data-id!='#{thread_id}'] .forum-nav-thread-link").removeClass("is-active")
@$(".forum-nav-thread[data-id='#{thread_id}'] .forum-nav-thread-link").addClass("is-active").find(".forum-nav-thread-wrapper-1").prepend('<span class="sr">' + gettext("Current conversation") + '</span>')
goHome: ->
@template = _.template($("#discussion-home-template").html())
$(".forum-content").html(@template)
$(".forum-nav-thread-list a").removeClass("is-active").find(".sr").remove()
$("input.email-setting").bind "click", @updateEmailNotifications
url = DiscussionUtil.urlFor("notifications_status",window.user.get("id"))
DiscussionUtil.safeAjax
url: url
type: "GET"
success: (response, textStatus) =>
if response.status
$('input.email-setting').attr('checked','checked')
else
$('input.email-setting').removeAttr('checked')
thread_id = null
@trigger("thread:removed")
#select all threads
isBrowseMenuVisible: =>
@$(".forum-nav-browse-menu-wrapper").is(":visible")
showBrowseMenu: =>
if not @isBrowseMenuVisible()
@$(".forum-nav-browse").addClass("is-active")
@$(".forum-nav-browse-menu-wrapper").show()
@$(".forum-nav-thread-list-wrapper").hide()
$(".forum-nav-browse-filter-input").focus()
$("body").bind "click", @hideBrowseMenu
@updateSidebar()
hideBrowseMenu: =>
if @isBrowseMenuVisible()
@$(".forum-nav-browse").removeClass("is-active")
@$(".forum-nav-browse-menu-wrapper").hide()
@$(".forum-nav-thread-list-wrapper").show()
$("body").unbind "click", @hideBrowseMenu
@updateSidebar()
toggleBrowseMenu: (event) =>
event.preventDefault()
event.stopPropagation()
if @isBrowseMenuVisible()
@hideBrowseMenu()
else
@showBrowseMenu()
# Given a menu item, get the text for it and its ancestors
# (starting from the root, separated by " / ")
getPathText: (item) ->
path = item.parents(".forum-nav-browse-menu-item").andSelf()
pathTitles = path.children(".forum-nav-browse-title").map((i, elem) -> $(elem).text()).get()
pathText = pathTitles.join(" / ")
filterTopics: (event) =>
query = $(event.target).val()
items = @$(".forum-nav-browse-menu-item")
if query.length == 0
items.show()
else
# If all filter terms occur in the path to an item then that item and
# all its descendants are displayed
items.hide()
items.each (i, item) =>
item = $(item)
if not item.is(":visible")
pathText = @getPathText(item).toLowerCase()
if query.split(" ").every((term) -> pathText.search(term.toLowerCase()) != -1)
path = item.parents(".forum-nav-browse-menu-item").andSelf()
path.add(item.find(".forum-nav-browse-menu-item")).show()
setCurrentTopicDisplay: (text) ->
@$(".forum-nav-browse-current").text(@fitName(text))
getNameWidth: (name) ->
test = $("<div>")
test.css
"font-size": @$(".forum-nav-browse-current").css('font-size')
opacity: 0
position: 'absolute'
left: -1000
top: -1000
$("body").append(test)
test.html(name)
width = test.width()
test.remove()
return width
fitName: (name) ->
@maxNameWidth = @$(".forum-nav-browse").width() -
@$(".forum-nav-browse .icon").outerWidth(true) -
@$(".forum-nav-browse-drop-arrow").outerWidth(true)
width = @getNameWidth(name)
if width < @maxNameWidth
return name
path = (x.replace /^\s+|\s+$/g, "" for x in name.split("/"))
prefix = ""
while path.length > 1
prefix = gettext("…") + "/"
path.shift()
partialName = prefix + path.join("/")
if @getNameWidth(partialName) < @maxNameWidth
return partialName
rawName = path[0]
name = prefix + rawName
while @getNameWidth(name) > @maxNameWidth
rawName = rawName[0...rawName.length-1]
name = prefix + rawName + gettext("…")
return name
selectTopicHandler: (event) ->
event.preventDefault()
@selectTopic $(event.target)
selectTopic: ($target) ->
@hideBrowseMenu()
@clearSearch()
item = $target.closest('.forum-nav-browse-menu-item')
@setCurrentTopicDisplay(@getPathText(item))
if item.hasClass("forum-nav-browse-menu-all")
@discussionIds = ""
@$('.forum-nav-filter-cohort').show()
@retrieveAllThreads()
else if item.hasClass("forum-nav-browse-menu-following")
@retrieveFollowed()
@$('.forum-nav-filter-cohort').hide()
else
allItems = item.find(".forum-nav-browse-menu-item").andSelf()
discussionIds = allItems.filter("[data-discussion-id]").map(
(i, elem) -> $(elem).data("discussion-id")
).get()
@retrieveDiscussions(discussionIds)
@$(".forum-nav-filter-cohort").toggle(item.data('cohorted') == true)
chooseFilter: (event) =>
@filter = $(".forum-nav-filter-main-control :selected").val()
@retrieveFirstPage()
chooseCohort: (event) =>
@group_id = @$('.forum-nav-filter-cohort-control :selected').val()
@retrieveFirstPage()
retrieveDiscussion: (discussion_id, callback=null) ->
url = DiscussionUtil.urlFor("retrieve_discussion", discussion_id)
DiscussionUtil.safeAjax
url: url
type: "GET"
success: (response, textStatus) =>
@collection.current_page = response.page
@collection.pages = response.num_pages
@collection.reset(response.discussion_data)
Content.loadContentInfos(response.annotated_content_info)
@displayedCollection.reset(@collection.models)# Don't think this is necessary because it's called on collection.reset
if callback?
callback()
retrieveDiscussions: (discussion_ids) ->
@discussionIds = discussion_ids.join(',')
@mode = 'commentables'
@retrieveFirstPage()
retrieveAllThreads: () ->
@mode = 'all'
@retrieveFirstPage()
retrieveFirstPage: (event)->
@collection.current_page = 0
@collection.reset()
@loadMorePages(event)
sortThreads: (event) ->
@displayedCollection.setSortComparator(@$(".forum-nav-sort-control").val())
@retrieveFirstPage(event)
performSearch: (event) ->
#event.which 13 represent the Enter button
if event.which == 13 or event.type == 'click'
event.preventDefault()
@hideBrowseMenu()
@setCurrentTopicDisplay(gettext("Search Results"))
text = @$(".forum-nav-search-input").val()
@searchFor(text)
searchFor: (text) ->
@clearSearchAlerts()
@clearFilters()
@mode = 'search'
@current_search = text
url = DiscussionUtil.urlFor("search")
#TODO: This might be better done by setting discussion.current_page=0 and calling discussion.loadMorePages
# Mainly because this currently does not reset any pagination variables which could cause problems.
# This doesn't use pagination either.
DiscussionUtil.safeAjax
$elem: @$(".forum-nav-search-input")
data: { text: text }
url: url
type: "GET"
dataType: 'json'
$loading: $
loadingCallback: =>
@$(".forum-nav-thread-list").html("<li class='forum-nav-load-more'>" + @getLoadingContent(gettext("Loading thread list")) + "</li>")
loadedCallback: =>
@$(".forum-nav-thread-list .forum-nav-load-more").remove()
success: (response, textStatus) =>
if textStatus == 'success'
# TODO: Augment existing collection?
@collection.reset(response.discussion_data)
Content.loadContentInfos(response.annotated_content_info)
@collection.current_page = response.page
@collection.pages = response.num_pages
if !_.isNull response.corrected_text
message = interpolate(
_.escape(gettext('No results found for %(original_query)s. Showing results for %(suggested_query)s.')),
{"original_query": "<em>" + _.escape(text) + "</em>", "suggested_query": "<em>" + response.corrected_text + "</em>"},
true
)
@addSearchAlert(message)
else if response.discussion_data.length == 0
@addSearchAlert(gettext('No threads matched your query.'))
# TODO: Perhaps reload user info so that votes can be updated.
# In the future we might not load all of a user's votes at once
# so this would probably be necessary anyway
@displayedCollection.reset(@collection.models) # Don't think this is necessary
@searchForUser(text) if text
searchForUser: (text) ->
DiscussionUtil.safeAjax
data: { username: text }
url: DiscussionUtil.urlFor("users")
type: "GET"
dataType: 'json'
error: =>
return
success: (response) =>
if response.users.length > 0
message = interpolate(
_.escape(gettext('Show posts by %(username)s.')),
{"username":
_.template('<a class="link-jump" href="<%= url %>"><%- username %></a>')({
url: DiscussionUtil.urlFor("user_profile", response.users[0].id),
username: response.users[0].username
})
},
true
)
@addSearchAlert(message)
clearSearch: ->
@$(".forum-nav-search-input").val("")
@current_search = ""
@clearSearchAlerts()
clearFilters: ->
@$(".forum-nav-filter-main-control").val("all")
@$(".forum-nav-filter-cohort-control").val("all")
retrieveFollowed: () =>
@mode = 'followed'
@retrieveFirstPage()
updateEmailNotifications: () =>
if $('input.email-setting').attr('checked')
DiscussionUtil.safeAjax
url: DiscussionUtil.urlFor("enable_notifications")
type: "POST"
error: () =>
$('input.email-setting').removeAttr('checked')
else
DiscussionUtil.safeAjax
url: DiscussionUtil.urlFor("disable_notifications")
type: "POST"
error: () =>
$('input.email-setting').attr('checked','checked')

View File

@@ -0,0 +1,21 @@
if Backbone?
class @DiscussionThreadProfileView extends Backbone.View
render: ->
@convertMath()
@abbreviateBody()
params = $.extend(@model.toJSON(),{permalink: @model.urlFor('retrieve')})
if not @model.get('anonymous')
params = $.extend(params, user:{username: @model.username, user_url: @model.user_url})
@$el.html(_.template($("#profile-thread-template").html())(params))
@$("span.timeago").timeago()
element = @$(".post-body")
if MathJax?
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element[0]]
@
convertMath: ->
@model.set('markdownBody', DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight @model.get('body'))
abbreviateBody: ->
abbreviated = DiscussionUtil.abbreviateHTML @model.get('markdownBody'), 140
@model.set('abbreviatedBody', abbreviated)

View File

@@ -0,0 +1,47 @@
if Backbone?
class @DiscussionThreadShowView extends DiscussionContentShowView
initialize: (options) ->
super()
@mode = options.mode or "inline" # allowed values are "tab" or "inline"
if @mode not in ["tab", "inline"]
throw new Error("invalid mode: " + @mode)
renderTemplate: ->
@template = _.template($("#thread-show-template").html())
context = $.extend(
{
mode: @mode,
flagged: @model.isFlagged(),
author_display: @getAuthorDisplay(),
cid: @model.cid,
readOnly: $('.discussion-module').data('read-only')
},
@model.attributes,
)
@template(context)
render: ->
@$el.html(@renderTemplate())
@delegateEvents()
@renderAttrs()
@$("span.timeago").timeago()
@convertMath()
@highlight @$(".post-body")
@highlight @$("h1,h3")
@
convertMath: ->
element = @$(".post-body")
element.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight element.text()
if MathJax?
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element[0]]
edit: (event) ->
@trigger "thread:edit", event
_delete: (event) ->
@trigger "thread:_delete", event
highlight: (el) ->
if el.html()
el.html(el.html().replace(/&lt;mark&gt;/g, "<mark>").replace(/&lt;\/mark&gt;/g, "</mark>"))

View File

@@ -0,0 +1,358 @@
if Backbone?
class @DiscussionThreadView extends DiscussionContentView
INITIAL_RESPONSE_PAGE_SIZE = 25
SUBSEQUENT_RESPONSE_PAGE_SIZE = 100
events:
"click .discussion-submit-post": "submitComment"
"click .add-response-btn": "scrollToAddResponse"
"click .forum-thread-expand": "expand"
"click .forum-thread-collapse": "collapse"
$: (selector) ->
@$el.find(selector)
isQuestion: ->
@model.get("thread_type") == "question"
initialize: (options) ->
super()
@mode = options.mode or "inline" # allowed values are "tab" or "inline"
@context = options.context or "course" # allowed values are "course" or "standalone"
@options = _.extend({}, options)
if @mode not in ["tab", "inline"]
throw new Error("invalid mode: " + @mode)
@readOnly = $(".discussion-module").data('read-only')
# Quick fix to have an actual model when we're receiving new models from
# the server.
@model.collection.on "reset", (collection) =>
id = @model.get("id")
@model = collection.get(id) if collection.get(id)
@createShowView()
@responses = new Comments()
@loadedResponses = false
if @isQuestion()
@markedAnswers = new Comments()
rerender: () ->
if @showView?
@showView.undelegateEvents()
@undelegateEvents()
@$el.empty()
@initialize(
mode: @mode
model: @model
el: @el
course_settings: @options.course_settings
topicId: @topicId
)
@render()
renderTemplate: ->
@template = _.template($("#thread-template").html())
container = $("#discussion-container")
if !container.length
# inline discussion
container = $(".discussion-module")
templateData = _.extend(
@model.toJSON(),
readOnly: @readOnly,
can_create_comment: container.data("user-create-comment")
)
@template(templateData)
render: ->
@$el.html(@renderTemplate())
@delegateEvents()
@renderShowView()
@renderAttrs()
@$("span.timeago").timeago()
@makeWmdEditor "reply-body"
@renderAddResponseButton()
@responses.on("add", (response) => @renderResponseToList(response, ".js-response-list", {}))
if @isQuestion()
@markedAnswers.on("add", (response) => @renderResponseToList(response, ".js-marked-answer-list", {collapseComments: true}))
if @mode == "tab"
# Without a delay, jQuery doesn't add the loading extension defined in
# utils.coffee before safeAjax is invoked, which results in an error
setTimeout((=> @loadInitialResponses()), 100)
@$(".post-tools").hide()
else # mode == "inline"
@collapse()
attrRenderer: $.extend({}, DiscussionContentView.prototype.attrRenderer, {
closed: (closed) ->
@$(".discussion-reply-new").toggle(not closed)
@$('.comment-form').closest('li').toggle(not closed)
@$(".action-vote").toggle(not closed)
@$(".display-vote").toggle(closed)
@renderAddResponseButton()
})
expand: (event) ->
if event
event.preventDefault()
@$el.addClass("expanded")
@$el.find(".post-body").text(@model.get("body"))
@showView.convertMath()
@$el.find(".forum-thread-expand").hide()
@$el.find(".forum-thread-collapse").show()
@$el.find(".post-extended-content").show()
if not @loadedResponses
@loadInitialResponses()
collapse: (event) ->
if event
event.preventDefault()
@$el.removeClass("expanded")
@$el.find(".post-body").text(@getAbbreviatedBody())
@showView.convertMath()
@$el.find(".forum-thread-expand").show()
@$el.find(".forum-thread-collapse").hide()
@$el.find(".post-extended-content").hide()
getAbbreviatedBody: ->
cached = @model.get("abbreviatedBody")
if cached
cached
else
abbreviated = DiscussionUtil.abbreviateString @model.get("body"), 140
@model.set("abbreviatedBody", abbreviated)
abbreviated
cleanup: ->
if @responsesRequest?
@responsesRequest.abort()
loadResponses: (responseLimit, elem, firstLoad) ->
# takeFocus take the page focus to response loading element while responses are being fetched.
# - When viewing in the Discussions tab, responses are loaded automatically, Do not scroll to the
# element(TNL-1530)
# - When viewing inline in courseware, user clicks 'expand' to open responses, Its ok to scroll to the
# element (Default)
takeFocus = if @mode == "tab" then false else true
@responsesRequest = DiscussionUtil.safeAjax
url: DiscussionUtil.urlFor('retrieve_single_thread', @model.get('commentable_id'), @model.id)
data:
resp_skip: @responses.size()
resp_limit: responseLimit if responseLimit
$elem: elem
$loading: elem
takeFocus: takeFocus
complete: =>
@responsesRequest = null
success: (data, textStatus, xhr) =>
Content.loadContentInfos(data['annotated_content_info'])
if @isQuestion()
@markedAnswers.add(data["content"]["endorsed_responses"])
@responses.add(
if @isQuestion()
then data["content"]["non_endorsed_responses"]
else data["content"]["children"]
)
@renderResponseCountAndPagination(
if @isQuestion()
then data["content"]["non_endorsed_resp_total"]
else data["content"]["resp_total"]
)
@trigger "thread:responses:rendered"
@loadedResponses = true
@$el.find('.discussion-article[data-id="' + @model.id + '"]').focus() # Sends focus to the discussion once the thread loads
error: (xhr, textStatus) =>
return if textStatus == 'abort'
if xhr.status == 404
DiscussionUtil.discussionAlert(
gettext("Sorry"),
gettext("The thread you selected has been deleted. Please select another thread.")
)
else if firstLoad
DiscussionUtil.discussionAlert(
gettext("Sorry"),
gettext("We had some trouble loading responses. Please reload the page.")
)
else
DiscussionUtil.discussionAlert(
gettext("Sorry"),
gettext("We had some trouble loading more responses. Please try again.")
)
loadInitialResponses: () ->
@loadResponses(INITIAL_RESPONSE_PAGE_SIZE, @$el.find(".js-response-list"), true)
renderResponseCountAndPagination: (responseTotal) =>
if @isQuestion() && @markedAnswers.length != 0
responseCountFormat = ngettext(
"%(numResponses)s other response",
"%(numResponses)s other responses",
responseTotal
)
else
responseCountFormat = ngettext(
"%(numResponses)s response",
"%(numResponses)s responses",
responseTotal
)
@$el.find(".response-count").html(
interpolate(
responseCountFormat,
{numResponses: responseTotal},
true
)
)
responsePagination = @$el.find(".response-pagination")
responsePagination.empty()
if responseTotal > 0
responsesRemaining = responseTotal - @responses.size()
showingResponsesText =
if responsesRemaining == 0
gettext("Showing all responses")
else
interpolate(
ngettext(
"Showing first response",
"Showing first %(numResponses)s responses",
@responses.size()
),
{numResponses: @responses.size()},
true
)
responsePagination.append($("<span>").addClass("response-display-count").html(
_.escape(showingResponsesText)
))
if responsesRemaining > 0
if responsesRemaining < SUBSEQUENT_RESPONSE_PAGE_SIZE
responseLimit = null
buttonText = gettext("Load all responses")
else
responseLimit = SUBSEQUENT_RESPONSE_PAGE_SIZE
buttonText = interpolate(
gettext("Load next %(numResponses)s responses"),
{numResponses: responseLimit},
true
)
loadMoreButton = $("<button>").addClass("load-response-button").html(
_.escape(buttonText)
)
loadMoreButton.click((event) => @loadResponses(responseLimit, loadMoreButton))
responsePagination.append(loadMoreButton)
renderResponseToList: (response, listSelector, options) =>
response.set('thread', @model)
view = new ThreadResponseView($.extend({model: response}, options))
view.on "comment:add", @addComment
view.on "comment:endorse", @endorseThread
view.render()
@$el.find(listSelector).append(view.el)
view.afterInsert()
renderAddResponseButton: =>
if @model.hasResponses() and @model.can('can_reply') and !@model.get('closed')
@$el.find('div.add-response').show()
else
@$el.find('div.add-response').hide()
scrollToAddResponse: (event) ->
event.preventDefault()
form = $(event.target).parents('article.discussion-article').find('form.discussion-reply-new')
$('html, body').scrollTop(form.offset().top)
form.find('.wmd-panel textarea').focus()
addComment: =>
@model.comment()
endorseThread: =>
@model.set 'endorsed', @$el.find(".action-answer.is-checked").length > 0
submitComment: (event) ->
event.preventDefault()
url = @model.urlFor('reply')
body = @getWmdContent("reply-body")
return if not body.trim().length
@setWmdContent("reply-body", "")
comment = new Comment(body: body, created_at: (new Date()).toISOString(), username: window.user.get("username"), votes: { up_count: 0 }, abuse_flaggers:[], endorsed: false, user_id: window.user.get("id"))
comment.set('thread', @model.get('thread'))
@renderResponseToList(comment, ".js-response-list")
@model.addComment()
@renderAddResponseButton()
DiscussionUtil.safeAjax
$elem: $(event.target)
url: url
type: "POST"
dataType: 'json'
data:
body: body
success: (data, textStatus) =>
comment.updateInfo(data.annotated_content_info)
comment.set(data.content)
edit: (event) =>
@createEditView()
@renderEditView()
createEditView: () ->
if @showView?
@showView.undelegateEvents()
@showView.$el.empty()
@showView = null
@editView = new DiscussionThreadEditView(
container: @$('.thread-content-wrapper')
model: @model
mode: @mode
context: @context
course_settings: @options.course_settings
)
@editView.bind "thread:updated thread:cancel_edit", @closeEditView
@editView.bind "comment:endorse", @endorseThread
renderSubView: (view) ->
view.setElement(@$('.thread-content-wrapper'))
view.render()
view.delegateEvents()
renderEditView: () ->
@editView.render()
createShowView: () ->
@showView = new DiscussionThreadShowView({model: @model, mode: @mode})
@showView.bind "thread:_delete", @_delete
@showView.bind "thread:edit", @edit
renderShowView: () ->
@renderSubView(@showView)
closeEditView: (event) =>
@createShowView()
@renderShowView()
@renderAttrs()
# next call is necessary to re-render the post action controls after
# submitting or cancelling a thread edit in inline mode.
@$el.find(".post-extended-content").show()
# If you use "delete" here, it will compile down into JS that includes the
# use of DiscussionThreadView.prototype.delete, and that will break IE8
# because "delete" is a keyword. So, using an underscore to prevent that.
_delete: (event) =>
url = @model.urlFor('_delete')
if not @model.can('can_delete')
return
if not confirm gettext("Are you sure you want to delete this post?")
return
@model.remove()
@showView.undelegateEvents()
@undelegateEvents()
@$el.empty()
$elem = $(event.target)
DiscussionUtil.safeAjax
$elem: $elem
url: url
type: "POST"
success: (response, textStatus) =>

View File

@@ -0,0 +1,207 @@
(function(Backbone) {
'use strict';
if (Backbone) {
this.DiscussionTopicMenuView = Backbone.View.extend({
events: {
'click .post-topic-button': 'toggleTopicDropdown',
'click .topic-menu-wrapper': 'handleTopicEvent',
'click .topic-filter-label': 'ignoreClick',
'keyup .topic-filter-input': 'filterDrop'
},
attributes: {
'class': 'post-field'
},
initialize: function(options) {
this.course_settings = options.course_settings;
this.currentTopicId = options.topicId;
this.maxNameWidth = 26;
_.bindAll(this,
'toggleTopicDropdown', 'handleTopicEvent', 'hideTopicDropdown', 'ignoreClick'
);
return this;
},
/**
* When the menu is expanded, a click on the body element (outside of the menu) or on a menu element
* should close the menu except when the target is the search field. To accomplish this, we have to ignore
* clicks on the search field by stopping the propagation of the event.
*/
ignoreClick: function(event) {
event.stopPropagation();
return this;
},
render: function() {
var context = _.clone(this.course_settings.attributes);
context.topics_html = this.renderCategoryMap(this.course_settings.get('category_map'));
this.$el.html(_.template($('#topic-template').html())(context));
this.dropdownButton = this.$('.post-topic-button');
this.topicMenu = this.$('.topic-menu-wrapper');
this.selectedTopic = this.$('.js-selected-topic');
this.hideTopicDropdown();
if (this.getCurrentTopicId()) {
this.setTopic(this.$('a.topic-title').filter('[data-discussion-id="' + this.getCurrentTopicId() + '"]'));
} else {
this.setTopic(this.$('a.topic-title').first());
}
return this.$el;
},
renderCategoryMap: function(map) {
var category_template = _.template($('#new-post-menu-category-template').html()),
entry_template = _.template($('#new-post-menu-entry-template').html());
return _.map(map.children, function(name) {
var html = '', entry;
if (_.has(map.entries, name)) {
entry = map.entries[name];
html = entry_template({
text: name,
id: entry.id,
is_cohorted: entry.is_cohorted
});
} else { // subcategory
html = category_template({
text: name,
entries: this.renderCategoryMap(map.subcategories[name])
});
}
return html;
}, this).join('');
},
toggleTopicDropdown: function(event) {
event.preventDefault();
event.stopPropagation();
if (this.menuOpen) {
this.hideTopicDropdown();
} else {
this.showTopicDropdown();
}
return this;
},
showTopicDropdown: function() {
this.menuOpen = true;
this.dropdownButton.addClass('dropped');
this.topicMenu.show();
$(document.body).on('click.topicMenu', this.hideTopicDropdown);
return this;
},
hideTopicDropdown: function() {
this.menuOpen = false;
this.dropdownButton.removeClass('dropped');
this.topicMenu.hide();
$(document.body).off('click.topicMenu');
return this;
},
handleTopicEvent: function(event) {
event.preventDefault();
event.stopPropagation();
this.setTopic($(event.target));
return this;
},
setTopic: function($target) {
if ($target.data('discussion-id')) {
this.topicText = this.getFullTopicName($target);
this.currentTopicId = $target.data('discussion-id');
this.setSelectedTopicName(this.topicText);
this.trigger('thread:topic_change', $target);
this.hideTopicDropdown();
}
return this;
},
getCurrentTopicId: function() {
return this.currentTopicId;
},
setSelectedTopicName: function(text) {
return this.selectedTopic.html(this.fitName(text));
},
/**
* Return full name for the `topicElement` if it is passed.
* Otherwise, full name for the current topic will be returned.
* @param {jQuery Element} [topicElement]
* @return {String}
*/
getFullTopicName: function(topicElement) {
var name;
if (topicElement) {
name = topicElement.html();
_.each(topicElement.parents('.topic-submenu'), function(item) {
name = $(item).siblings('.topic-title').text() + ' / ' + name;
});
return name;
} else {
return this.topicText;
}
},
// @TODO move into utils.coffee
fitName: function(name) {
var ellipsisText = gettext('…'),
partialName, path, rawName;
if (name.length < this.maxNameWidth) {
return name;
} else {
path = _.map(name.split('/'), function(item){
return item.replace(/^\s+|\s+$/g, '');
});
while (path.length > 1) {
path.shift();
partialName = ellipsisText + '/' + path.join('/');
if (partialName.length > this.maxNameWidth) {
return partialName;
}
}
rawName = path[0];
name = ellipsisText + ' / ' + rawName;
while (name.length > this.maxNameWidth) {
rawName = rawName.slice(0, this.maxNameWidth);
name = ellipsisText + ' / ' + rawName + ' ' + ellipsisText;
}
}
return name;
},
// TODO: this helper class duplicates functionality in DiscussionThreadListView.filterTopics
// for use with a very similar category dropdown in the New Post form. The two menus' implementations
// should be merged into a single reusable view.
filterDrop: function (e) {
var $drop, $items, query;
$drop = $(e.target).parents('.topic-menu-wrapper');
query = $(e.target).val();
$items = $drop.find('.topic-menu-item');
if (query.length === 0) {
$items.removeClass('hidden');
return;
}
$items.addClass('hidden');
$items.each(function (_index, item) {
var path, pathText, pathTitles;
path = $(item).parents(".topic-menu-item").andSelf();
pathTitles = path.children(".topic-title").map(function (_, elem) {
return $(elem).text();
}).get();
pathText = pathTitles.join(" / ").toLowerCase();
if (query.split(" ").every(function (term) {
return pathText.search(term.toLowerCase()) !== -1;
})) {
$(item).removeClass('hidden');
$(item).find('.topic-menu-item').removeClass('hidden');
$(item).parents('.topic-menu-item').removeClass('hidden');
}
});
}
});
}
}).call(this, Backbone);

View File

@@ -0,0 +1,43 @@
if Backbone?
class @DiscussionUserProfileView extends Backbone.View
events:
"click .discussion-paginator a": "changePage"
initialize: (options) ->
super()
@page = options.page
@numPages = options.numPages
@discussion = new Discussion()
@discussion.on("reset", @render)
@discussion.reset(@collection, {silent: false})
render: () =>
@$el.html(_.template($("#user-profile-template").html())({threads: @discussion.models}))
@discussion.map (thread) ->
new DiscussionThreadProfileView(el: @$("article#thread_#{thread.id}"), model: thread).render()
baseUri = URI(window.location).removeSearch("page")
pageUrlFunc = (page) -> baseUri.clone().addSearch("page", page)
paginationParams = DiscussionUtil.getPaginationParams(@page, @numPages, pageUrlFunc)
@$el.find(".discussion-pagination").html(_.template($("#pagination-template").html())(paginationParams))
changePage: (event) ->
event.preventDefault()
url = $(event.target).attr("href")
DiscussionUtil.safeAjax
$elem: @$el
$loading: $(event.target)
takeFocus: true
url: url
type: "GET"
dataType: "json"
success: (response, textStatus, xhr) =>
@page = response.page
@numPages = response.num_pages
@discussion.reset(response.discussion_data, {silent: false})
history.pushState({}, "", url)
$("html, body").animate({ scrollTop: 0 });
error: =>
DiscussionUtil.discussionAlert(
gettext("Sorry"),
gettext("We had some trouble loading the page you requested. Please try again.")
)

View File

@@ -0,0 +1,125 @@
if Backbone?
class @NewPostView extends Backbone.View
initialize: (options) ->
@mode = options.mode or "inline" # allowed values are "tab" or "inline"
if @mode not in ["tab", "inline"]
throw new Error("invalid mode: " + @mode)
@course_settings = options.course_settings
@is_commentable_cohorted = options.is_commentable_cohorted
@topicId = options.topicId
render: () ->
context = _.clone(@course_settings.attributes)
_.extend(context, {
cohort_options: @getCohortOptions(),
is_commentable_cohorted: @is_commentable_cohorted,
mode: @mode,
form_id: @mode + (if @topicId then "-" + @topicId else "")
})
@$el.html(_.template($("#new-post-template").html())(context))
threadTypeTemplate = _.template($("#thread-type-template").html());
if $('.js-group-select').is(':disabled')
$('.group-selector-wrapper').addClass('disabled')
@addField(threadTypeTemplate({form_id: _.uniqueId("form-")}));
if @isTabMode()
@topicView = new DiscussionTopicMenuView {
topicId: @topicId
course_settings: @course_settings
}
@topicView.on('thread:topic_change', @toggleGroupDropdown)
@addField(@topicView.render())
DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), "js-post-body"
addField: (fieldView) ->
@$('.forum-new-post-form-wrapper').append fieldView
isTabMode: () ->
@mode is "tab"
getCohortOptions: () ->
if @course_settings.get("is_cohorted") and DiscussionUtil.isPrivilegedUser()
user_cohort_id = $("#discussion-container").data("user-cohort-id")
_.map @course_settings.get("cohorts"), (cohort) ->
{value: cohort.id, text: cohort.name, selected: cohort.id==user_cohort_id}
else
null
events:
"submit .forum-new-post-form": "createPost"
"change .post-option-input": "postOptionChange"
"click .cancel": "cancel"
"reset .forum-new-post-form": "updateStyles"
toggleGroupDropdown: ($target) ->
if $target.data('cohorted')
$('.js-group-select').prop('disabled', false);
$('.group-selector-wrapper').removeClass('disabled')
else
$('.js-group-select').val('').prop('disabled', true);
$('.group-selector-wrapper').addClass('disabled')
postOptionChange: (event) ->
$target = $(event.target)
$optionElem = $target.closest(".post-option")
if $target.is(":checked")
$optionElem.addClass("is-enabled")
else
$optionElem.removeClass("is-enabled")
createPost: (event) ->
event.preventDefault()
thread_type = @$(".post-type-input:checked").val()
title = @$(".js-post-title").val()
body = @$(".js-post-body").find(".wmd-input").val()
group = @$(".js-group-select option:selected").attr("value")
anonymous = false || @$(".js-anon").is(":checked")
anonymous_to_peers = false || @$(".js-anon-peers").is(":checked")
follow = false || @$(".js-follow").is(":checked")
topicId = if @isTabMode() then @topicView.getCurrentTopicId() else @topicId
url = DiscussionUtil.urlFor('create_thread', topicId)
DiscussionUtil.safeAjax
$elem: $(event.target)
$loading: $(event.target) if event
url: url
type: "POST"
dataType: 'json'
data:
thread_type: thread_type
title: title
body: body
anonymous: anonymous
anonymous_to_peers: anonymous_to_peers
auto_subscribe: follow
group_id: group
error: DiscussionUtil.formErrorHandler(@$(".post-errors"))
success: (response, textStatus) =>
# TODO: Move this out of the callback, this makes it feel sluggish
thread = new Thread response['content']
@$el.hide()
@resetForm()
@collection.add thread
cancel: (event) ->
event.preventDefault()
if not confirm gettext("Your post will be discarded.")
return
@trigger('newPost:cancel')
@resetForm()
resetForm: =>
@$(".forum-new-post-form")[0].reset()
DiscussionUtil.clearFormErrors(@$(".post-errors"))
@$(".wmd-preview p").html("")
if @isTabMode()
@topicView.setTopic(@$("a.topic-title").first())
updateStyles: =>
# form reset doesn't change the style of checkboxes so this event is to do that job
setTimeout(
(=> @$(".post-option-input").trigger("change")),
1
)

View File

@@ -0,0 +1,25 @@
if Backbone?
class @ResponseCommentEditView extends Backbone.View
events:
"click .post-update": "update"
"click .post-cancel": "cancel_edit"
$: (selector) ->
@$el.find(selector)
initialize: ->
super()
render: ->
@template = _.template($("#response-comment-edit-template").html())
@$el.html(@template(@model.toJSON()))
@delegateEvents()
DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), "edit-comment-body"
@
update: (event) ->
@trigger "comment:update", event
cancel_edit: (event) ->
@trigger "comment:cancel_edit", event

View File

@@ -0,0 +1,44 @@
if Backbone?
class @ResponseCommentShowView extends DiscussionContentShowView
tagName: "li"
render: ->
@template = _.template($("#response-comment-show-template").html())
@$el.html(
@template(
_.extend(
{
cid: @model.cid,
author_display: @getAuthorDisplay(),
readOnly: $('.discussion-module').data('read-only')
},
@model.attributes
)
)
)
@delegateEvents()
@renderAttrs()
@$el.find(".timeago").timeago()
@convertMath()
@addReplyLink()
@
addReplyLink: () ->
if @model.hasOwnProperty('parent')
name = @model.parent.get('username') ? gettext("anonymous")
html = "<a href='#comment_#{@model.parent.id}'>@#{name}</a>: "
p = @$('.response-body p:first')
p.prepend(html)
convertMath: ->
body = @$el.find(".response-body")
body.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight body.text()
if MathJax?
MathJax.Hub.Queue ["Typeset", MathJax.Hub, body[0]]
_delete: (event) =>
@trigger "comment:_delete", event
edit: (event) =>
@trigger "comment:edit", event

View File

@@ -0,0 +1,85 @@
if Backbone?
class @ResponseCommentView extends DiscussionContentView
tagName: "li"
$: (selector) ->
@$el.find(selector)
initialize: ->
super()
render: ->
@renderShowView()
@
renderSubView: (view) ->
view.setElement(@$el)
view.render()
view.delegateEvents()
renderShowView: () ->
if not @showView?
if @editView?
@editView.undelegateEvents()
@editView.$el.empty()
@editView = null
@showView = new ResponseCommentShowView(model: @model)
@showView.bind "comment:_delete", @_delete
@showView.bind "comment:edit", @edit
@renderSubView(@showView)
renderEditView: () ->
if not @editView?
if @showView?
@showView.undelegateEvents()
@showView.$el.empty()
@showView = null
@editView = new ResponseCommentEditView(model: @model)
@editView.bind "comment:update", @update
@editView.bind "comment:cancel_edit", @cancelEdit
@renderSubView(@editView)
_delete: (event) =>
event.preventDefault()
if not @model.can('can_delete')
return
if not confirm gettext("Are you sure you want to delete this comment?")
return
url = @model.urlFor('_delete')
$elem = $(event.target)
DiscussionUtil.safeAjax
$elem: $elem
url: url
type: "POST"
success: (response, textStatus) =>
@model.remove()
@$el.remove()
error: =>
DiscussionUtil.discussionAlert(
gettext("Sorry"),
gettext("We had some trouble deleting this comment. Please try again.")
)
cancelEdit: (event) =>
@trigger "comment:cancel_edit", event
@renderShowView()
edit: (event) =>
@trigger "comment:edit", event
@renderEditView()
update: (event) =>
newBody = @editView.$(".edit-comment-body textarea").val()
url = DiscussionUtil.urlFor("update_comment", @model.id)
DiscussionUtil.safeAjax
$elem: $(event.target)
$loading: $(event.target)
url: url
type: "POST"
dataType: "json"
data:
body: newBody
error: DiscussionUtil.formErrorHandler(@$(".edit-comment-form-errors"))
success: (response, textStatus) =>
@model.set("body", newBody)
@cancelEdit()

View File

@@ -0,0 +1,25 @@
if Backbone?
class @ThreadResponseEditView extends Backbone.View
events:
"click .post-update": "update"
"click .post-cancel": "cancel_edit"
$: (selector) ->
@$el.find(selector)
initialize: ->
super()
render: ->
@template = _.template($("#thread-response-edit-template").html())
@$el.html(@template(@model.toJSON()))
@delegateEvents()
DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), "edit-post-body"
@
update: (event) ->
@trigger "response:update", event
cancel_edit: (event) ->
@trigger "response:cancel_edit", event

View File

@@ -0,0 +1,38 @@
if Backbone?
class @ThreadResponseShowView extends DiscussionContentShowView
initialize: ->
super()
@listenTo(@model, "change", @render)
renderTemplate: ->
@template = _.template($("#thread-response-show-template").html())
context = _.extend(
{
cid: @model.cid,
author_display: @getAuthorDisplay(),
endorser_display: @getEndorserDisplay(),
readOnly: $('.discussion-module').data('read-only')
},
@model.attributes
)
@template(context)
render: ->
@$el.html(@renderTemplate())
@delegateEvents()
@renderAttrs()
@$el.find(".posted-details .timeago").timeago()
@convertMath()
@
convertMath: ->
element = @$(".response-body")
element.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight element.text()
if MathJax?
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element[0]]
edit: (event) ->
@trigger "response:edit", event
_delete: (event) ->
@trigger "response:_delete", event

View File

@@ -0,0 +1,227 @@
if Backbone?
class @ThreadResponseView extends DiscussionContentView
tagName: "li"
className: "forum-response"
events:
"click .discussion-submit-comment": "submitComment"
"focus .wmd-input": "showEditorChrome"
$: (selector) ->
@$el.find(selector)
initialize: (options) ->
@collapseComments = options.collapseComments
@createShowView()
@readOnly = $('.discussion-module').data('read-only')
renderTemplate: ->
@template = _.template($("#thread-response-template").html())
container = $("#discussion-container")
if !container.length
# inline discussion
container = $(".discussion-module")
templateData = _.extend(
@model.toJSON(),
wmdId: @model.id ? (new Date()).getTime(),
create_sub_comment: container.data("user-create-subcomment"),
readOnly: @readOnly
)
@template(templateData)
render: ->
@$el.addClass("response_" + @model.get("id"))
@$el.html(@renderTemplate())
@delegateEvents()
@renderShowView()
@renderAttrs()
if @model.get("thread").get("closed")
@hideCommentForm()
@renderComments()
@
afterInsert: ->
@makeWmdEditor "comment-body"
@hideEditorChrome()
hideEditorChrome: ->
@$('.wmd-button-row').hide()
@$('.wmd-preview-container').hide()
@$('.wmd-input').css({
height: '35px',
padding: '5px'
})
@$('.comment-post-control').hide()
showEditorChrome: ->
@$('.wmd-button-row').show()
@$('.wmd-preview-container').show()
@$('.comment-post-control').show()
@$('.wmd-input').css({
height: '125px',
padding: '10px'
})
renderComments: ->
comments = new Comments()
@commentViews = []
comments.comparator = (comment) ->
comment.get('created_at')
collectComments = (comment) ->
comments.add(comment)
children = new Comments(comment.get('children'))
children.each (child) ->
child.parent = comment
collectComments(child)
@model.get('comments').each collectComments
comments.each (comment) => @renderComment(comment, false, null)
if @collapseComments && comments.length
@$(".comments").hide()
@$(".action-show-comments").on("click", (event) =>
event.preventDefault()
@$(".action-show-comments").hide()
@$(".comments").show()
)
else
@$(".action-show-comments").hide()
renderComment: (comment) =>
comment.set('thread', @model.get('thread'))
view = new ResponseCommentView(model: comment)
view.render()
if @readOnly
@$el.find('.comments').append(view.el)
else
@$el.find(".comments .new-comment").before(view.el)
view.bind "comment:edit", (event) =>
@cancelEdit(event) if @editView?
@cancelCommentEdits()
@hideCommentForm()
view.bind "comment:cancel_edit", () => @showCommentForm()
@commentViews.push(view)
view
submitComment: (event) ->
event.preventDefault()
url = @model.urlFor('reply')
body = @getWmdContent("comment-body")
return if not body.trim().length
@setWmdContent("comment-body", "")
comment = new Comment(body: body, created_at: (new Date()).toISOString(), username: window.user.get("username"), abuse_flaggers:[], user_id: window.user.get("id"), id:"unsaved")
view = @renderComment(comment)
@hideEditorChrome()
@trigger "comment:add", comment
DiscussionUtil.safeAjax
$elem: $(event.target)
url: url
type: "POST"
dataType: 'json'
data:
body: body
success: (response, textStatus) ->
comment.set(response.content)
comment.updateInfo(response.annotated_content_info)
view.render() # This is just to update the id for the most part, but might be useful in general
_delete: (event) =>
event.preventDefault()
if not @model.can('can_delete')
return
if not confirm gettext("Are you sure you want to delete this response?")
return
url = @model.urlFor('_delete')
@model.remove()
@$el.remove()
$elem = $(event.target)
DiscussionUtil.safeAjax
$elem: $elem
url: url
type: "POST"
success: (response, textStatus) =>
createEditView: () ->
if @showView?
@showView.$el.empty()
if @editView?
@editView.model = @model
else
@editView = new ThreadResponseEditView(model: @model)
@editView.bind "response:update", @update
@editView.bind "response:cancel_edit", @cancelEdit
renderSubView: (view) ->
view.setElement(@$('.discussion-response'))
view.render()
view.delegateEvents()
renderEditView: () ->
@renderSubView(@editView)
cancelCommentEdits: () ->
_.each(@commentViews, (view) -> view.cancelEdit())
hideCommentForm: () ->
@$('.comment-form').closest('li').hide()
showCommentForm: () ->
@$('.comment-form').closest('li').show()
createShowView: () ->
if @editView?
@editView.$el.empty()
if @showView?
@showView.model = @model
else
@showView = new ThreadResponseShowView(model: @model)
@showView.bind "response:_delete", @_delete
@showView.bind "response:edit", @edit
@showView.on "comment:endorse", => @trigger("comment:endorse")
renderShowView: () ->
@renderSubView(@showView)
cancelEdit: (event) =>
event.preventDefault()
@createShowView()
@renderShowView()
@showCommentForm()
edit: (event) =>
@createEditView()
@renderEditView()
@cancelCommentEdits()
@hideCommentForm()
update: (event) =>
newBody = @editView.$(".edit-post-body textarea").val()
url = DiscussionUtil.urlFor('update_comment', @model.id)
DiscussionUtil.safeAjax
$elem: $(event.target)
$loading: $(event.target) if event
url: url
type: "POST"
dataType: 'json'
data:
body: newBody
error: DiscussionUtil.formErrorHandler(@$(".edit-post-form-errors"))
success: (response, textStatus) =>
@editView.$(".edit-post-body textarea").val("").attr("prev-text", "")
@editView.$(".wmd-preview p").html("")
@model.set
body: newBody
@createShowView()
@renderShowView()
@showCommentForm()

View File

@@ -0,0 +1,115 @@
describe 'All Content', ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
describe 'Staff and TA Content', ->
beforeEach ->
DiscussionUtil.loadRoles({"Moderator": [567], "Administrator": [567], "Community TA": [567]})
it 'anonymous thread should not include login role label', ->
anon_content = new Content
anon_content.initialize
expect(anon_content.get 'staff_authored').toBe false
expect(anon_content.get 'community_ta_authored').toBe false
it 'general thread should include login role label', ->
anon_content = new Content { user_id: '567' }
anon_content.initialize
expect(anon_content.get 'staff_authored').toBe true
expect(anon_content.get 'community_ta_authored').toBe true
describe 'Content', ->
beforeEach ->
@content = new Content {
id: '01234567',
user_id: '567',
course_id: 'edX/999/test',
body: 'this is some content',
abuse_flaggers: ['123']
}
it 'should exist', ->
expect(Content).toBeDefined()
it 'is initialized correctly', ->
@content.initialize
expect(Content.contents['01234567']).toEqual @content
expect(@content.get 'id').toEqual '01234567'
expect(@content.get 'user_url').toEqual '/courses/edX/999/test/discussion/forum/users/567'
expect(@content.get 'children').toEqual []
expect(@content.get 'comments').toEqual(jasmine.any(Comments))
it 'can update info', ->
@content.updateInfo {
ability: {'can_edit': true},
voted: true,
subscribed: true
}
expect(@content.get 'ability').toEqual {'can_edit': true}
expect(@content.get 'voted').toEqual true
expect(@content.get 'subscribed').toEqual true
it 'can be flagged for abuse', ->
@content.flagAbuse()
expect(@content.get 'abuse_flaggers').toEqual ['123', '567']
it 'can be unflagged for abuse', ->
temp_array = []
temp_array.push(window.user.get('id'))
@content.set("abuse_flaggers",temp_array)
@content.unflagAbuse()
expect(@content.get 'abuse_flaggers').toEqual []
describe 'Comments', ->
beforeEach ->
@comment1 = new Comment {id: '123'}
@comment2 = new Comment {id: '345'}
it 'can contain multiple comments', ->
myComments = new Comments
expect(myComments.length).toEqual 0
myComments.add @comment1
expect(myComments.length).toEqual 1
myComments.add @comment2
expect(myComments.length).toEqual 2
it 'returns results to the find method', ->
myComments = new Comments
myComments.add @comment1
expect(myComments.find('123')).toBe @comment1
it 'can be endorsed', ->
DiscussionUtil.loadRoles(
{"Moderator": [111], "Administrator": [222], "Community TA": [333]}
)
@discussionThread = new Thread({id: 1, thread_type: "discussion", user_id: 99})
@discussionResponse = new Comment({id: 1, thread: @discussionThread})
@questionThread = new Thread({id: 1, thread_type: "question", user_id: 99})
@questionResponse = new Comment({id: 1, thread: @questionThread})
# mod
window.user = new DiscussionUser({id: 111})
expect(@discussionResponse.canBeEndorsed()).toBe(true)
expect(@questionResponse.canBeEndorsed()).toBe(true)
# admin
window.user = new DiscussionUser({id: 222})
expect(@discussionResponse.canBeEndorsed()).toBe(true)
expect(@questionResponse.canBeEndorsed()).toBe(true)
# TA
window.user = new DiscussionUser({id: 333})
expect(@discussionResponse.canBeEndorsed()).toBe(true)
expect(@questionResponse.canBeEndorsed()).toBe(true)
# thread author
window.user = new DiscussionUser({id: 99})
expect(@discussionResponse.canBeEndorsed()).toBe(false)
expect(@questionResponse.canBeEndorsed()).toBe(true)
# anyone else
window.user = new DiscussionUser({id: 999})
expect(@discussionResponse.canBeEndorsed()).toBe(false)
expect(@questionResponse.canBeEndorsed()).toBe(false)

View File

@@ -0,0 +1,48 @@
describe 'DiscussionUtil', ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
describe "updateWithUndo", ->
it "calls through to safeAjax with correct params, and reverts the model in case of failure", ->
deferred = $.Deferred()
spyOn($, "ajax").and.returnValue(deferred)
spyOn(DiscussionUtil, "safeAjax").and.callThrough()
model = new Backbone.Model({hello: false, number: 42})
updates = {hello: "world"}
# the ajax request should fire and the model should be updated
res = DiscussionUtil.updateWithUndo(model, updates, {foo: "bar"}, "error message")
expect(DiscussionUtil.safeAjax).toHaveBeenCalled()
expect(model.attributes).toEqual({hello: "world", number: 42})
# the error message callback should be set up correctly
spyOn(DiscussionUtil, "discussionAlert")
DiscussionUtil.safeAjax.calls.mostRecent().args[0].error()
expect(DiscussionUtil.discussionAlert).toHaveBeenCalledWith("Sorry", "error message")
# if the ajax call ends in failure, the model state should be reverted
deferred.reject()
expect(model.attributes).toEqual({hello: false, number: 42})
it "rolls back the changes if the associated element is disabled", ->
spyOn(DiscussionUtil, "safeAjax").and.callThrough()
model = new Backbone.Model({hello: false, number: 42})
updates = {hello: "world"}
# This is the element that is disabled/enabled while the ajax request is
# in progress
$elem = jasmine.createSpyObj('$elem', ['attr'])
$elem.attr.and.returnValue(true)
res = DiscussionUtil.updateWithUndo(model, updates, {foo: "bar", $elem:$elem}, "error message")
expect($elem.attr).toHaveBeenCalledWith("disabled")
expect(DiscussionUtil.safeAjax).toHaveBeenCalled()
expect(model.attributes).toEqual({hello: false, number: 42})
failed = false
res.fail(() => failed = true)
expect(failed).toBe(true);

View File

@@ -0,0 +1,43 @@
describe "DiscussionContentView", ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
DiscussionSpecHelper.setUnderscoreFixtures()
@threadData = {
id: '01234567',
user_id: '567',
course_id: 'edX/999/test',
body: 'this is a thread',
created_at: '2013-04-03T20:08:39Z',
abuse_flaggers: ['123'],
votes: {up_count: '42'},
type: "thread",
roles: []
}
@thread = new Thread(@threadData)
@view = new DiscussionContentView({ model: @thread })
@view.setElement($('#fixture-element'))
@view.render()
it 'defines the tag', ->
expect($('#jasmine-fixtures')).toExist
expect(@view.tagName).toBeDefined
expect(@view.el.tagName.toLowerCase()).toBe 'div'
it "defines the class", ->
# spyOn @content, 'initialize'
expect(@view.model).toBeDefined();
it 'is tied to the model', ->
expect(@view.model).toBeDefined();
it 'can be flagged for abuse', ->
@thread.flagAbuse()
expect(@thread.get 'abuse_flaggers').toEqual ['123', '567']
it 'can be unflagged for abuse', ->
temp_array = []
temp_array.push(window.user.get('id'))
@thread.set("abuse_flaggers",temp_array)
@thread.unflagAbuse()
expect(@thread.get 'abuse_flaggers').toEqual []

View File

@@ -0,0 +1,120 @@
(function() {
'use strict';
describe('DiscussionThreadEditView', function() {
var testUpdate, testCancel;
beforeEach(function() {
DiscussionSpecHelper.setUpGlobals();
DiscussionSpecHelper.setUnderscoreFixtures();
spyOn(DiscussionUtil, 'makeWmdEditor');
this.threadData = DiscussionViewSpecHelper.makeThreadWithProps({
'commentable_id': 'test_topic',
'title': 'test thread title'
});
this.thread = new Thread(this.threadData);
this.course_settings = DiscussionSpecHelper.makeCourseSettings();
this.createEditView = function (options) {
options = _.extend({
container: $('#fixture-element'),
model: this.thread,
mode: 'tab',
course_settings: this.course_settings
}, options);
this.view = new DiscussionThreadEditView(options);
this.view.render();
};
});
testUpdate = function(view, thread, newTopicId, newTopicName) {
spyOn($, 'ajax').and.callFake(function(params) {
expect(params.url.path()).toEqual(DiscussionUtil.urlFor('update_thread', 'dummy_id'));
expect(params.data.thread_type).toBe('discussion');
expect(params.data.commentable_id).toBe(newTopicId);
expect(params.data.title).toBe('changed thread title');
params.success();
return {always: function() {}};
});
view.$el.find('a.topic-title').filter(function (idx, el) {
return $(el).data('discussionId') === newTopicId;
}).click(); // set new topic
view.$('.edit-post-title').val('changed thread title'); // set new title
view.$("label[for$='post-type-discussion']").click(); // set new thread type
view.$('.post-update').click();
expect($.ajax).toHaveBeenCalled();
expect(thread.get('title')).toBe('changed thread title');
expect(thread.get('thread_type')).toBe('discussion');
expect(thread.get('commentable_id')).toBe(newTopicId);
expect(thread.get('courseware_title')).toBe(newTopicName);
expect(view.$('.edit-post-title')).toHaveValue('');
expect(view.$('.wmd-preview p')).toHaveText('');
};
it('can save new data correctly in tab mode', function() {
this.createEditView();
testUpdate(this.view, this.thread, 'other_topic', 'Other Topic');
});
it('can save new data correctly in inline mode', function() {
this.createEditView({"mode": "inline"});
testUpdate(this.view, this.thread, 'other_topic', 'Other Topic');
});
testCancel = function(view) {
view.$('.post-cancel').click();
expect($('.edit-post-form')).not.toExist();
};
it('can close the view in tab mode', function() {
this.createEditView();
testCancel(this.view);
});
it('can close the view in inline mode', function() {
this.createEditView({"mode": "inline"});
testCancel(this.view);
});
describe('renderComments', function() {
beforeEach(function() {
this.course_settings = new DiscussionCourseSettings({
'category_map': {
'children': ['Topic', 'General', 'Basic Question'],
'entries': {
'Topic': {
'is_cohorted': true,
'id': 'topic'
},
"General": {
"sort_key": "General",
"is_cohorted": false,
"id": "6.00.1x_General"
},
"Basic Question": {
"is_cohorted": false,
"id": "6>00'1x\"Basic_Question"
}
}
},
'is_cohorted': true
});
});
it('can save new data correctly for current discussion id without dots', function () {
this.createEditView({topicId: "topic"});
testUpdate(this.view, this.thread, "6.00.1x_General", "General");
});
it('can save new data correctly for current discussion id with dots', function () {
this.createEditView({topicId: "6.00.1x_General"});
testUpdate(this.view, this.thread, "6>00\'1x\"Basic_Question", "Basic Question");
});
it('can save new data correctly for current discussion id with special characters', function () {
this.createEditView({topicId: "6>00\'1x\"Basic_Question"});
testUpdate(this.view, this.thread, "6.00.1x_General", "General");
});
});
});
}).call(this);

View File

@@ -0,0 +1,602 @@
describe "DiscussionThreadListView", ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
DiscussionSpecHelper.setUnderscoreFixtures()
appendSetFixtures("""
<script type="text/template" id="thread-list-template">
<div class="forum-nav-header">
<button type="button" class="forum-nav-browse" id="forum-nav-browse" aria-haspopup="true">
<span class="icon fa fa-bars" aria-hidden="true"></span>
<span class="sr">Discussion topics; currently listing: </span>
<span class="forum-nav-browse-current">All Discussions</span>
</button>
<form class="forum-nav-search">
<label>
<span class="sr">Search all posts</span>
<input class="forum-nav-search-input" id="forum-nav-search" type="text" placeholder="Search all posts">
<span class="icon fa fa-search" aria-hidden="true"></span>
</label>
</form>
</div>
<div class="forum-nav-browse-menu-wrapper" style="display: none">
<form class="forum-nav-browse-filter">
<label>
<span class="sr">Filter Topics</span>
<input type="text" class="forum-nav-browse-filter-input" placeholder="filter topics">
</label>
</form>
<ul class="forum-nav-browse-menu">
<li class="forum-nav-browse-menu-item forum-nav-browse-menu-all">
<a href="#" class="forum-nav-browse-title">All Discussions</a>
</li>
<li class="forum-nav-browse-menu-item forum-nav-browse-menu-following">
<a href="#" class="forum-nav-browse-title"><span class="icon fa fa-star" aria-hidden="true"></span>Posts I'm Following</a>
</li>
<li class="forum-nav-browse-menu-item">
<a href="#" class="forum-nav-browse-title">Parent</a>
<ul class="forum-nav-browse-submenu">
<li class="forum-nav-browse-menu-item">
<a href="#" class="forum-nav-browse-title">Target</a>
<ul class="forum-nav-browse-submenu">
<li
class="forum-nav-browse-menu-item"
data-discussion-id="child"
data-cohorted="false"
>
<a href="#" class="forum-nav-browse-title">Child</a>
</li>
</ul>
<li
class="forum-nav-browse-menu-item"
data-discussion-id="sibling"
data-cohorted="false"
>
<a href="#" class="forum-nav-browse-title">Sibling</a>
</li>
</ul>
</li>
<li
class="forum-nav-browse-menu-item"
data-discussion-id="other"
data-cohorted="true"
>
<a href="#" class="forum-nav-browse-title">Other Category</a>
</li>
</ul>
</div>
<div class="forum-nav-thread-list-wrapper" id="sort-filter-wrapper" tabindex="-1">
<div class="forum-nav-refine-bar">
<label class="forum-nav-filter-main">
<select class="forum-nav-filter-main-control">
<option value="all">Show all</option>
<option value="unread">Unread</option>
<option value="unanswered">Unanswered</option>
<option value="flagged">Flagged</option>
</select>
</label>
<% if (isCohorted && isPrivilegedUser) { %>
<label class="forum-nav-filter-cohort">
<span class="sr">Cohort:</span>
<select class="forum-nav-filter-cohort-control">
<option value="">in all cohorts</option>
<option value="1">Cohort1</option>
<option value="2">Cohort2</option>
</select>
</label>
<% } %>
<label class="forum-nav-sort">
<select class="forum-nav-sort-control">
<option value="activity">by recent activity</option>
<option value="comments">by most activity</option>
<option value="votes">by most votes</option>
</select>
</label>
</div>
</div>
<div class="search-alerts"></div>
<ul class="forum-nav-thread-list"></ul>
</script>
""")
@threads = [
DiscussionViewSpecHelper.makeThreadWithProps({
id: "1",
title: "Thread1",
votes: {up_count: '20'},
pinned: true,
comments_count: 1,
created_at: '2013-04-03T20:08:39Z',
}),
DiscussionViewSpecHelper.makeThreadWithProps({
id: "2",
title: "Thread2",
votes: {up_count: '42'},
comments_count: 2,
created_at: '2013-04-03T20:07:39Z',
}),
DiscussionViewSpecHelper.makeThreadWithProps({
id: "3",
title: "Thread3",
votes: {up_count: '12'},
comments_count: 3,
created_at: '2013-04-03T20:06:39Z',
}),
DiscussionViewSpecHelper.makeThreadWithProps({
id: "4",
title: "Thread4",
votes: {up_count: '25'},
comments_count: 0,
pinned: true,
created_at: '2013-04-03T20:05:39Z',
}),
]
deferred = $.Deferred()
spyOn($, "ajax").and.returnValue(deferred);
@discussion = new Discussion([])
@view = new DiscussionThreadListView(
collection: @discussion,
el: $("#fixture-element"),
courseSettings: new DiscussionCourseSettings({is_cohorted: true})
)
@view.render()
setupAjax = (callback) ->
$.ajax.and.callFake(
(params) =>
if callback
callback(params)
params.success({discussion_data: [], page: 1, num_pages: 1})
{always: ->}
)
renderSingleThreadWithProps = (props) ->
makeView(new Discussion([new Thread(DiscussionViewSpecHelper.makeThreadWithProps(props))])).render()
makeView = (discussion) ->
return new DiscussionThreadListView(
el: $("#fixture-element"),
collection: discussion,
courseSettings: new DiscussionCourseSettings({is_cohorted: true})
)
expectFilter = (filterVal) ->
$.ajax.and.callFake((params) ->
_.each(["unread", "unanswered", "flagged"], (paramName)->
if paramName == filterVal
expect(params.data[paramName]).toEqual(true)
else
expect(params.data[paramName]).toBeUndefined()
)
{always: ->}
)
describe "should filter correctly", ->
_.each(["all", "unread", "unanswered", "flagged"], (filterVal) ->
it "for #{filterVal}", ->
expectFilter(filterVal)
@view.$(".forum-nav-filter-main-control").val(filterVal).change()
expect($.ajax).toHaveBeenCalled()
)
describe "cohort selector", ->
it "should not be visible to students", ->
expect(@view.$(".forum-nav-filter-cohort-control:visible")).not.toExist()
it "should allow moderators to select visibility", ->
DiscussionSpecHelper.makeModerator()
@view.render()
expectedGroupId = null
setupAjax((params) => expect(params.data.group_id).toEqual(expectedGroupId))
_.each(
[
{val: "", expectedGroupId: undefined},
{val: "1", expectedGroupId: "1"},
{val: "2", expectedGroupId: "2"}
],
(optionInfo) =>
expectedGroupId = optionInfo.expectedGroupId
@view.$(".forum-nav-filter-cohort-control").val(optionInfo.val).change()
expect($.ajax).toHaveBeenCalled()
$.ajax.calls.reset()
)
it "search should clear filter", ->
expectFilter(null)
@view.$(".forum-nav-filter-main-control").val("flagged")
@view.searchFor("foobar")
expect(@view.$(".forum-nav-filter-main-control").val()).toEqual("all")
checkThreadsOrdering = (view, sort_order, type) ->
expect(view.$el.find(".forum-nav-thread").children().length).toEqual(4)
expect(view.$el.find(".forum-nav-thread:nth-child(1) .forum-nav-thread-title").text()).toEqual(sort_order[0])
expect(view.$el.find(".forum-nav-thread:nth-child(2) .forum-nav-thread-title").text()).toEqual(sort_order[1])
expect(view.$el.find(".forum-nav-thread:nth-child(3) .forum-nav-thread-title").text()).toEqual(sort_order[2])
expect(view.$el.find(".forum-nav-thread:nth-child(4) .forum-nav-thread-title").text()).toEqual(sort_order[3])
expect(view.$el.find(".forum-nav-sort-control").val()).toEqual(type)
describe "thread rendering should be correct", ->
checkRender = (threads, type, sort_order) ->
discussion = new Discussion(_.map(threads, (thread) -> new Thread(thread)), {pages: 1, sort: type})
view = makeView(discussion)
view.render()
checkThreadsOrdering(view, sort_order, type)
expect(view.$el.find(".forum-nav-thread-comments-count:visible").length).toEqual(if type == "votes" then 0 else 4)
expect(view.$el.find(".forum-nav-thread-votes-count:visible").length).toEqual(if type == "votes" then 4 else 0)
if type == "votes"
expect(
_.map(
view.$el.find(".forum-nav-thread-votes-count"),
(element) -> $(element).text().trim()
)
).toEqual(["+25 votes", "+20 votes", "+42 votes", "+12 votes"])
it "with sort preference activity", ->
checkRender(@threads, "activity", ["Thread1", "Thread2", "Thread3", "Thread4"])
it "with sort preference votes", ->
checkRender(@threads, "votes", ["Thread4", "Thread1", "Thread2", "Thread3"])
it "with sort preference comments", ->
checkRender(@threads, "comments", ["Thread1", "Thread4", "Thread3", "Thread2"])
describe "Sort change should be correct", ->
changeSorting = (threads, selected_type, new_type, sort_order) ->
discussion = new Discussion(_.map(threads, (thread) -> new Thread(thread)), {pages: 1, sort: selected_type})
view = makeView(discussion)
view.render()
sortControl = view.$el.find(".forum-nav-sort-control")
expect(sortControl.val()).toEqual(selected_type)
sorted_threads = []
if new_type == 'activity'
sorted_threads = [threads[0], threads[3], threads[1], threads[2]]
else if new_type == 'comments'
sorted_threads = [threads[0], threads[3], threads[2], threads[1]]
else if new_type == 'votes'
sorted_threads = [threads[3], threads[0], threads[1], threads[2]]
$.ajax.and.callFake((params) =>
params.success(
{"discussion_data":sorted_threads, page:1, num_pages:1}
)
{always: ->}
)
sortControl.val(new_type).change()
expect($.ajax).toHaveBeenCalled()
checkThreadsOrdering(view, sort_order, new_type)
it "with sort preference activity", ->
changeSorting(@threads, "comments", "activity", ["Thread1", "Thread4", "Thread3", "Thread2"])
it "with sort preference votes", ->
changeSorting(@threads, "activity", "votes", ["Thread4", "Thread1", "Thread2", "Thread3"])
it "with sort preference comments", ->
changeSorting(@threads, "votes", "comments", ["Thread1", "Thread4", "Thread3", "Thread2"])
describe "search alerts", ->
testAlertMessages = (expectedMessages) ->
expect($(".search-alert .message").map( ->
$(@).html()
).get()).toEqual(expectedMessages)
it "renders and removes search alerts", ->
testAlertMessages []
foo = @view.addSearchAlert("foo")
testAlertMessages ["foo"]
bar = @view.addSearchAlert("bar")
testAlertMessages ["foo", "bar"]
@view.removeSearchAlert(foo.cid)
testAlertMessages ["bar"]
@view.removeSearchAlert(bar.cid)
testAlertMessages []
it "clears all search alerts", ->
@view.addSearchAlert("foo")
@view.addSearchAlert("bar")
@view.addSearchAlert("baz")
testAlertMessages ["foo", "bar", "baz"]
@view.clearSearchAlerts()
testAlertMessages []
describe "search spell correction", ->
beforeEach ->
spyOn(@view, "searchForUser")
testCorrection = (view, correctedText) ->
spyOn(view, "addSearchAlert")
$.ajax.and.callFake(
(params) =>
params.success(
{discussion_data: [], page: 42, num_pages: 99, corrected_text: correctedText}, 'success'
)
{always: ->}
)
view.searchFor("dummy")
expect($.ajax).toHaveBeenCalled()
it "adds a search alert when an alternate term was searched", ->
testCorrection(@view, "foo")
expect(@view.addSearchAlert.calls.count()).toEqual(1)
expect(@view.addSearchAlert.calls.mostRecent().args[0]).toMatch(/foo/)
it "does not add a search alert when no alternate term was searched", ->
testCorrection(@view, null)
expect(@view.addSearchAlert.calls.count()).toEqual(1)
expect(@view.addSearchAlert.calls.mostRecent().args[0]).toMatch(/no threads matched/i)
it "clears search alerts when a new search is performed", ->
spyOn(@view, "clearSearchAlerts")
spyOn(DiscussionUtil, "safeAjax")
@view.searchFor("dummy")
expect(@view.clearSearchAlerts).toHaveBeenCalled()
it "clears search alerts when the underlying collection changes", ->
spyOn(@view, "clearSearchAlerts")
spyOn(@view, "renderThread")
@view.collection.trigger("change", new Thread({id: 1}))
expect(@view.clearSearchAlerts).toHaveBeenCalled()
describe "Search events", ->
it "perform search when enter pressed inside search textfield", ->
setupAjax()
spyOn(@view, "searchFor")
@view.$el.find(".forum-nav-search-input").trigger($.Event("keydown", {which: 13}))
expect(@view.searchFor).toHaveBeenCalled()
it "perform search when search icon is clicked", ->
setupAjax()
spyOn(@view, "searchFor")
@view.$el.find(".fa-search").click()
expect(@view.searchFor).toHaveBeenCalled()
describe "username search", ->
it "makes correct ajax calls", ->
$.ajax.and.callFake(
(params) =>
expect(params.data.username).toEqual("testing-username")
expect(params.url.path()).toEqual(DiscussionUtil.urlFor("users"))
params.success(
{users: []}, 'success'
)
{always: ->}
)
@view.searchForUser("testing-username")
expect($.ajax).toHaveBeenCalled()
setAjaxResults = (threadSuccess, userResult) ->
# threadSuccess is a boolean indicating whether the thread search ajax call should succeed
# userResult is the value that should be returned as data from the username search ajax call
$.ajax.and.callFake(
(params) =>
if params.data.text and threadSuccess
params.success(
{discussion_data: [], page: 42, num_pages: 99, corrected_text: "dummy"},
"success"
)
else if params.data.username
params.success(
{users: userResult},
"success"
)
{always: ->}
)
it "gets called after a thread search succeeds", ->
spyOn(@view, "searchForUser").and.callThrough()
setAjaxResults(true, [])
@view.searchFor("gizmo")
expect(@view.searchForUser).toHaveBeenCalled()
expect($.ajax.calls.mostRecent().args[0].data.username).toEqual("gizmo")
it "does not get called after a thread search fails", ->
spyOn(@view, "searchForUser").and.callThrough()
setAjaxResults(false, [])
@view.searchFor("gizmo")
expect(@view.searchForUser).not.toHaveBeenCalled()
it "adds a search alert when an username was matched", ->
spyOn(@view, "addSearchAlert")
setAjaxResults(true, [{username: "gizmo", id: "1"}])
@view.searchForUser("dummy")
expect($.ajax).toHaveBeenCalled()
expect(@view.addSearchAlert).toHaveBeenCalled()
expect(@view.addSearchAlert.calls.mostRecent().args[0]).toMatch(/gizmo/)
it "does not add a search alert when no username was matched", ->
spyOn(@view, "addSearchAlert")
setAjaxResults(true, [])
@view.searchForUser("dummy")
expect($.ajax).toHaveBeenCalled()
expect(@view.addSearchAlert).not.toHaveBeenCalled()
describe "post type renders correctly", ->
it "for discussion", ->
renderSingleThreadWithProps({thread_type: "discussion"})
expect($(".forum-nav-thread-wrapper-0 .icon")).toHaveClass("fa-comments")
expect($(".forum-nav-thread-wrapper-0 .sr")).toHaveText("discussion")
it "for answered question", ->
renderSingleThreadWithProps({thread_type: "question", endorsed: true})
expect($(".forum-nav-thread-wrapper-0 .icon")).toHaveClass("fa-check-square-o")
expect($(".forum-nav-thread-wrapper-0 .sr")).toHaveText("answered question")
it "for unanswered question", ->
renderSingleThreadWithProps({thread_type: "question", endorsed: false})
expect($(".forum-nav-thread-wrapper-0 .icon")).toHaveClass("fa-question")
expect($(".forum-nav-thread-wrapper-0 .sr")).toHaveText("unanswered question")
describe "post labels render correctly", ->
beforeEach ->
@moderatorId = "42"
@administratorId = "43"
@communityTaId = "44"
DiscussionUtil.loadRoles({
"Moderator": [parseInt(@moderatorId)],
"Administrator": [parseInt(@administratorId)],
"Community TA": [parseInt(@communityTaId)],
})
it "for pinned", ->
renderSingleThreadWithProps({pinned: true})
expect($(".post-label-pinned").length).toEqual(1)
it "for following", ->
renderSingleThreadWithProps({subscribed: true})
expect($(".post-label-following").length).toEqual(1)
it "for moderator", ->
renderSingleThreadWithProps({user_id: @moderatorId})
expect($(".post-label-by-staff").length).toEqual(1)
it "for administrator", ->
renderSingleThreadWithProps({user_id: @administratorId})
expect($(".post-label-by-staff").length).toEqual(1)
it "for community TA", ->
renderSingleThreadWithProps({user_id: @communityTaId})
expect($(".post-label-by-community-ta").length).toEqual(1)
it "when none should be present", ->
renderSingleThreadWithProps({})
expect($(".forum-nav-thread-labels").length).toEqual(0)
describe "browse menu", ->
afterEach ->
# Remove handler added to make browse menu disappear
$("body").unbind("click")
expectBrowseMenuVisible = (isVisible) ->
expect($(".forum-nav-browse-menu:visible").length).toEqual(if isVisible then 1 else 0)
expect($(".forum-nav-thread-list-wrapper:visible").length).toEqual(if isVisible then 0 else 1)
it "should not be visible by default", ->
expectBrowseMenuVisible(false)
it "should show when header button is clicked", ->
$(".forum-nav-browse").click()
expectBrowseMenuVisible(true)
describe "when shown", ->
beforeEach ->
$(".forum-nav-browse").click()
it "should hide when header button is clicked", ->
$(".forum-nav-browse").click()
expectBrowseMenuVisible(false)
it "should hide when a click outside the menu occurs", ->
$(".forum-nav-search-input").click()
expectBrowseMenuVisible(false)
it "should hide when a search is executed", ->
setupAjax()
$(".forum-nav-search-input").trigger($.Event("keydown", {which: 13}))
expectBrowseMenuVisible(false)
it "should hide when a category is clicked", ->
$(".forum-nav-browse-title")[0].click()
expectBrowseMenuVisible(false)
it "should still be shown when filter input is clicked", ->
$(".forum-nav-browse-filter-input").click()
expectBrowseMenuVisible(true)
describe "filtering", ->
checkFilter = (filterText, expectedItems) ->
$(".forum-nav-browse-filter-input").val(filterText).keyup()
visibleItems = $(".forum-nav-browse-title:visible").map(
(i, elem) -> $(elem).text()
).get()
expect(visibleItems).toEqual(expectedItems)
it "should be case-insensitive", ->
checkFilter("other", ["Other Category"])
it "should match partial words", ->
checkFilter("ateg", ["Other Category"])
it "should show ancestors and descendants of matches", ->
checkFilter("Target", ["Parent", "Target", "Child"])
it "should handle multiple words regardless of order", ->
checkFilter("Following Posts", ["Posts I'm Following"])
it "should handle multiple words in different depths", ->
checkFilter("Parent Child", ["Parent", "Target", "Child"])
describe "selecting an item", ->
it "should clear the search box", ->
setupAjax()
$(".forum-nav-search-input").val("foobar")
$(".forum-nav-browse-menu-following .forum-nav-browse-title").click()
expect($(".forum-nav-search-input").val()).toEqual("")
it "should change the button text", ->
setupAjax()
$(".forum-nav-browse-menu-following .forum-nav-browse-title").click()
expect($(".forum-nav-browse-current").text()).toEqual("Posts I'm Following")
it "should show/hide the cohort selector", ->
DiscussionSpecHelper.makeModerator()
@view.render()
setupAjax()
_.each(
[
{selector: ".forum-nav-browse-menu-all", cohortVisibility: true},
{selector: ".forum-nav-browse-menu-following", cohortVisibility: false},
{
selector: ".forum-nav-browse-menu-item:has(.forum-nav-browse-menu-item .forum-nav-browse-menu-item)",
cohortVisibility: false
},
{selector: "[data-discussion-id=child]", cohortVisibility: false},
{selector: "[data-discussion-id=other]", cohortVisibility: true}
],
(itemInfo) =>
@view.$("#{itemInfo.selector} > .forum-nav-browse-title").click()
expect(@view.$(".forum-nav-filter-cohort").is(":visible")).toEqual(itemInfo.cohortVisibility)
)
testSelectionRequest = (callback, itemText) ->
setupAjax(callback)
$(".forum-nav-browse-title:contains(#{itemText})").click()
expect($.ajax).toHaveBeenCalled()
it "should get all discussions", ->
testSelectionRequest(
(params) -> expect(params.url.path()).toEqual(DiscussionUtil.urlFor("threads")),
"All"
)
it "should get followed threads", ->
testSelectionRequest(
(params) ->
expect(params.url.path()).toEqual(
DiscussionUtil.urlFor("followed_threads", window.user.id)
)
,
"Following"
)
expect($.ajax.calls.mostRecent().args[0].data.group_id).toBeUndefined();
it "should get threads for the selected leaf", ->
testSelectionRequest(
(params) ->
expect(params.url.path()).toEqual(DiscussionUtil.urlFor("search"))
expect(params.data.commentable_ids).toEqual("child")
,
"Child"
)
it "should get threads for children of the selected intermediate node", ->
testSelectionRequest(
(params) ->
expect(params.url.path()).toEqual(DiscussionUtil.urlFor("search"))
expect(params.data.commentable_ids).toEqual("child,sibling")
,
"Parent"
)

View File

@@ -0,0 +1,110 @@
# -*- coding: utf-8 -*-
describe "DiscussionThreadProfileView", ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
DiscussionSpecHelper.setUnderscoreFixtures()
@threadData = {
id: "1",
body: "dummy body",
discussion: new Discussion()
abuse_flaggers: [],
commentable_id: 'dummy_discussion',
votes: {up_count: "42"},
created_at: "2014-09-09T20:11:08Z"
}
@imageTag = '<img src="https://www.google.com.pk/images/srpr/logo11w.png">'
window.MathJax = { Hub: { Queue: -> } }
makeView = (thread) ->
view = new DiscussionThreadProfileView(model: thread)
spyConvertMath(view)
return view
makeThread = (threadData) ->
thread = new Thread(threadData)
thread.discussion = new Discussion()
return thread
spyConvertMath = (view) ->
spyOn(view, "convertMath").and.callFake( ->
@model.set('markdownBody', @model.get('body'))
)
checkPostWithImages = (numberOfImages, truncatedText, threadData, imageTag) ->
expectedHtml = '<p>'
threadData.body = '<p>'
testText = ''
expectedText = ''
if truncatedText
testText = new Array(100).join('test ')
expectedText = testText.substring(0, 139)+ '…'
else
testText = 'Test body'
expectedText = 'Test body'
for i in [0..numberOfImages-1]
threadData.body = threadData.body + imageTag
if i == 0
expectedHtml = expectedHtml + imageTag
else
expectedHtml = expectedHtml + '<em>image omitted</em>'
threadData.body = threadData.body + '<em>' + testText + '</em></p>'
if numberOfImages > 1
expectedHtml = expectedHtml + '<em>' + expectedText + '</em></p><p><em>Some images in this post have been omitted</em></p>'
else
expectedHtml = expectedHtml + '<em>' + expectedText + '</em></p>'
view = makeView(makeThread(threadData))
view.render()
expect(view.$el.find(".post-body").html()).toEqual(expectedHtml)
checkBody = (truncated, view, threadData) ->
view.render()
if not truncated
expect(view.model.get("body")).toEqual(view.model.get("abbreviatedBody"))
expect(view.$el.find(".post-body").html()).toEqual(threadData.body)
else
expect(view.model.get("body")).not.toEqual(view.model.get("abbreviatedBody"))
expect(view.$el.find(".post-body").html()).not.toEqual(threadData.body)
outputHtmlStripped = view.$el.find(".post-body").html().replace(/(<([^>]+)>)/ig,"");
outputHtmlStripped = outputHtmlStripped.replace("Some images in this post have been omitted","")
outputHtmlStripped = outputHtmlStripped.replace("image omitted","")
inputHtmlStripped = threadData.body.replace(/(<([^>]+)>)/ig,"");
expectedOutput = inputHtmlStripped.substring(0, 139)+ '…'
expect(outputHtmlStripped).toEqual(expectedOutput)
expect(view.$el.find(".post-body").html().indexOf("…")).toBeGreaterThan(0)
describe "Body markdown should be correct", ->
it "untruncated text without markdown body", ->
@threadData.body = "Test body"
view = makeView(makeThread(@threadData))
checkBody(false, view, @threadData)
it "truncated text without markdown body", ->
@threadData.body = new Array(100).join("test ")
view = makeView(makeThread(@threadData))
checkBody(true, view, @threadData)
it "untruncated text with markdown body", ->
@threadData.body = '<p>' + @imageTag + '<em>Google top search engine</em></p>'
view = makeView(makeThread(@threadData))
checkBody(false, view, @threadData)
it "truncated text with markdown body", ->
testText = new Array(100).join("test ")
@threadData.body = '<p>' + @imageTag + @imageTag + '<em>' + testText + '</em></p>'
view = makeView(makeThread(@threadData))
checkBody(true, view, @threadData)
for numImages in [1, 2, 10]
for truncatedText in [true, false]
it "body with #{numImages} images and #{if truncatedText then "truncated" else "untruncated"} text", ->
checkPostWithImages(numImages, truncatedText, @threadData, @imageTag)
it "check the thread retrieve url", ->
thread = makeThread(@threadData)
expect(thread.urlFor('retrieve')).toBe('/courses/edX/999/test/discussion/forum/dummy_discussion/threads/1')

View File

@@ -0,0 +1,159 @@
describe "DiscussionThreadShowView", ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
DiscussionSpecHelper.setUnderscoreFixtures()
@user = DiscussionUtil.getUser()
@threadData = {
id: "dummy",
user_id: @user.id,
username: @user.get('username'),
course_id: $$course_id,
title: "dummy title",
body: "this is a thread",
created_at: "2013-04-03T20:08:39Z",
abuse_flaggers: [],
votes: {up_count: 42},
thread_type: "discussion",
closed: false,
pinned: false,
type: "thread" # TODO - silly that this needs to be explicitly set
}
@thread = new Thread(@threadData)
@view = new DiscussionThreadShowView({ model: @thread })
@view.setElement($("#fixture-element"))
spyOn(@view, "convertMath")
describe "voting", ->
it "renders the vote state correctly", ->
DiscussionViewSpecHelper.checkRenderVote(@view, @thread)
it "votes correctly via click", ->
DiscussionViewSpecHelper.checkUpvote(@view, @thread, @user, $.Event("click"))
it "votes correctly via spacebar", ->
DiscussionViewSpecHelper.checkUpvote(@view, @thread, @user, $.Event("keydown", {which: 32}))
it "unvotes correctly via click", ->
DiscussionViewSpecHelper.checkUnvote(@view, @thread, @user, $.Event("click"))
it "unvotes correctly via spacebar", ->
DiscussionViewSpecHelper.checkUnvote(@view, @thread, @user, $.Event("keydown", {which: 32}))
describe "pinning", ->
expectPinnedRendered = (view, model) ->
pinned = model.get('pinned')
button = view.$el.find(".action-pin")
expect(button.hasClass("is-checked")).toBe(pinned)
expect(button.attr("aria-checked")).toEqual(pinned.toString())
it "renders the pinned state correctly", ->
@view.render()
expectPinnedRendered(@view, @thread)
@thread.set('pinned', false)
@view.render()
expectPinnedRendered(@view, @thread)
@thread.set('pinned', true)
@view.render()
expectPinnedRendered(@view, @thread)
it "exposes the pinning control only to authorized users", ->
@thread.updateInfo({ability: {can_openclose: false}})
@view.render()
expect(@view.$el.find(".action-pin").closest(".is-hidden")).toExist()
@thread.updateInfo({ability: {can_openclose: true}})
@view.render()
expect(@view.$el.find(".action-pin").closest(".is-hidden")).not.toExist()
it "handles events correctly", ->
@view.render()
DiscussionViewSpecHelper.checkButtonEvents(@view, "togglePin", ".action-pin")
describe "labels", ->
expectOneElement = (view, selector, visible=true) =>
view.render()
elements = view.$el.find(selector)
expect(elements.length).toEqual(1)
if visible
expect(elements).not.toHaveClass("is-hidden")
else
expect(elements).toHaveClass("is-hidden")
it 'displays the closed label when appropriate', ->
expectOneElement(@view, '.post-label-closed', false)
@thread.set('closed', true)
expectOneElement(@view, '.post-label-closed')
it 'displays the pinned label when appropriate', ->
expectOneElement(@view, '.post-label-pinned', false)
@thread.set('pinned', true)
expectOneElement(@view, '.post-label-pinned')
it 'displays the reported label when appropriate for a non-staff user', ->
expectOneElement(@view, '.post-label-reported', false)
# flagged by current user - should be labelled
@thread.set('abuse_flaggers', [DiscussionUtil.getUser().id])
expectOneElement(@view, '.post-label-reported')
# flagged by some other user but not the current one - should not be labelled
@thread.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
expectOneElement(@view, '.post-label-reported', false)
it 'displays the reported label when appropriate for a flag moderator', ->
DiscussionSpecHelper.makeModerator()
expectOneElement(@view, '.post-label-reported', false)
# flagged by current user - should be labelled
@thread.set('abuse_flaggers', [DiscussionUtil.getUser().id])
expectOneElement(@view, '.post-label-reported')
# flagged by some other user but not the current one - should still be labelled
@thread.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
expectOneElement(@view, '.post-label-reported')
describe "author display", ->
beforeEach ->
@thread.set('user_url', 'test_user_url')
checkUserLink = (element, is_ta, is_staff) ->
expect(element.find('a.username').length).toEqual(1)
expect(element.find('a.username').text()).toEqual('test_user')
expect(element.find('a.username').attr('href')).toEqual('test_user_url')
expect(element.find('.user-label-community-ta').length).toEqual(if is_ta then 1 else 0)
expect(element.find('.user-label-staff').length).toEqual(if is_staff then 1 else 0)
it "renders correctly for a student-authored thread", ->
$el = $('#fixture-element').html(@view.getAuthorDisplay())
checkUserLink($el, false, false)
it "renders correctly for a community TA-authored thread", ->
@thread.set('community_ta_authored', true)
$el = $('#fixture-element').html(@view.getAuthorDisplay())
checkUserLink($el, true, false)
it "renders correctly for a staff-authored thread", ->
@thread.set('staff_authored', true)
$el = $('#fixture-element').html(@view.getAuthorDisplay())
checkUserLink($el, false, true)
it "renders correctly for an anonymously-authored thread", ->
@thread.set('username', null)
$el = $('#fixture-element').html(@view.getAuthorDisplay())
expect($el.find('a.username').length).toEqual(0)
expect($el.text()).toMatch(/^(\s*)anonymous(\s*)$/)
describe "cohorting", ->
it "renders correctly for an uncohorted thread", ->
@view.render()
expect(@view.$('.group-visibility-label').text().trim()).toEqual(
'This post is visible to everyone.'
)
it "renders correctly for a cohorted thread", ->
@thread.set('group_id', '1')
@thread.set('group_name', 'Mock Cohort')
@view.render()
expect(@view.$('.group-visibility-label').text().trim()).toEqual(
'This post is visible only to Mock Cohort.'
)

View File

@@ -0,0 +1,410 @@
describe "DiscussionThreadView", ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
DiscussionSpecHelper.setUnderscoreFixtures()
jasmine.clock().install()
@threadData = DiscussionViewSpecHelper.makeThreadWithProps({})
@thread = new Thread(@threadData)
@discussion = new Discussion(@thread)
deferred = $.Deferred();
spyOn($, "ajax").and.returnValue(deferred);
# Avoid unnecessary boilerplate
spyOn(DiscussionThreadShowView.prototype, "convertMath")
spyOn(DiscussionContentView.prototype, "makeWmdEditor")
spyOn(DiscussionUtil, "makeWmdEditor")
spyOn(DiscussionUtil, "setWmdContent")
spyOn(ThreadResponseShowView.prototype, "convertMath")
afterEach ->
$.ajax.calls.reset()
jasmine.clock().uninstall()
renderWithContent = (view, content) ->
$.ajax.and.callFake((params) =>
params.success(
createAjaxResponseJson(content, false),
'success'
)
{always: ->}
)
view.render()
jasmine.clock().tick(100)
renderWithTestResponses = (view, count, options) ->
renderWithContent(
view,
_.extend(
{
resp_total: count,
children: if count > 0 then (createTestResponseJson(index) for index in [1..count]) else []
},
options
)
)
createTestResponseJson = (index) ->
{
user_id: window.user.id,
body: "Response " + index,
id: "id_" + index,
created_at: "2015-01-01T22:20:28Z"
}
assertContentVisible = (view, selector, visible) ->
content = view.$el.find(selector)
expect(content.length).toBeGreaterThan(0)
content.each (i, elem) ->
expect($(elem).is(":visible")).toEqual(visible)
assertExpandedContentVisible = (view, expanded) ->
expect(view.$el.hasClass("expanded")).toEqual(expanded)
assertContentVisible(view, ".post-extended-content", expanded)
assertContentVisible(view, ".forum-thread-expand", not expanded)
assertContentVisible(view, ".forum-thread-collapse", expanded)
assertResponseCountAndPaginationCorrect = (view, countText, displayCountText, buttonText) ->
expect(view.$el.find(".response-count").text()).toEqual(countText)
if displayCountText
expect(view.$el.find(".response-display-count").text()).toEqual(displayCountText)
else
expect(view.$el.find(".response-display-count").length).toEqual(0)
if buttonText
expect(view.$el.find(".load-response-button").text()).toEqual(buttonText)
else
expect(view.$el.find(".load-response-button").length).toEqual(0)
createAjaxResponseJson = (content, can_act) ->
{
content: content,
annotated_content_info: {
ability: {
editable: can_act,
can_delete: can_act,
can_reply: can_act,
can_vote: can_act
}
}
}
postResponse = (view, index) ->
testResponseJson = createTestResponseJson(index)
responseText = testResponseJson.body
spyOn(view, "getWmdContent").and.returnValue(responseText)
$.ajax.and.callFake((params) =>
expect(params.type).toEqual("POST")
expect(params.data.body).toEqual(responseText)
params.success(
createAjaxResponseJson(testResponseJson, true),
'success'
)
{always: ->}
)
view.$(".discussion-submit-post").click()
describe "closed and open Threads", ->
createDiscussionThreadView = (originallyClosed, mode) ->
threadData = DiscussionViewSpecHelper.makeThreadWithProps({closed: originallyClosed})
thread = new Thread(threadData)
discussion = new Discussion(thread)
view = new DiscussionThreadView(
model: thread
el: $("#fixture-element")
mode: mode
course_settings: DiscussionSpecHelper.makeCourseSettings()
)
renderWithTestResponses(view, 1)
if mode == "inline"
view.expand()
spyOn(DiscussionUtil, "updateWithUndo").and.callFake(
(model, updates, safeAjaxParams, errorMsg) ->
model.set(updates)
)
view
checkCommentForm = (originallyClosed, mode) ->
view = createDiscussionThreadView(originallyClosed, mode)
expect(view.$('.comment-form').closest('li').is(":visible")).toBe(not originallyClosed)
expect(view.$(".discussion-reply-new").is(":visible")).toBe(not originallyClosed)
view.$(".action-close").click()
expect(view.$('.comment-form').closest('li').is(":visible")).toBe(originallyClosed)
expect(view.$(".discussion-reply-new").is(":visible")).toBe(originallyClosed)
checkVoteDisplay = (originallyClosed, mode) ->
view = createDiscussionThreadView(originallyClosed, mode)
expect(view.$('.thread-main-wrapper .action-vote').is(":visible")).toBe(not originallyClosed)
expect(view.$('.thread-main-wrapper .display-vote').is(":visible")).toBe(originallyClosed)
view.$(".action-close").click()
expect(view.$('.action-vote').is(":visible")).toBe(originallyClosed)
expect(view.$('.display-vote').is(":visible")).toBe(not originallyClosed)
_.each(["tab", "inline"], (mode) =>
it "Test that in #{mode} mode when a closed thread is opened the comment form is displayed", ->
checkCommentForm(true, mode)
it "Test that in #{mode} mode when a open thread is closed the comment form is hidden", ->
checkCommentForm(false, mode)
it "Test that in #{mode} mode when a closed thread is opened the vote button is displayed and vote count is hidden", ->
checkVoteDisplay(true, mode)
it "Test that in #{mode} mode when a open thread is closed the vote button is hidden and vote count is displayed", ->
checkVoteDisplay(false, mode)
)
describe "tab mode", ->
beforeEach ->
@view = new DiscussionThreadView(
model: @thread
el: $("#fixture-element")
mode: "tab"
course_settings: DiscussionSpecHelper.makeCourseSettings()
)
describe "responses", ->
it "can post a first response", ->
# Initially render a test post (made by someone else) with zero responses
renderWithTestResponses(@view, 0)
postResponse(@view, 1)
expect(@view.$(".forum-response").length).toBe(1)
# At this point, there are 2 DiscussionContentViews, the main post and the response.
# Each an .action-edit button, but only 1 (the response) should be available.
expect(@view.$(".post-actions-list").find(".action-edit").parent(".is-hidden").length).toBe(1)
expect(@view.$(".response-actions-list").find(".action-edit").parent().not(".is-hidden").length).toBe(1)
it "can post a second response", ->
# Initially render a test post (made by someone else) with a single response (made by the current learner)
renderWithTestResponses(@view, 1)
expect(@view.$(".forum-response").length).toBe(1)
# Post should not be editable, response should be
expect(@view.$(".post-actions-list").find(".action-edit").parent(".is-hidden").length).toBe(1)
expect(@view.$(".response-actions-list").find(".action-edit").parent().not(".is-hidden").length).toBe(1)
# Now make a second response. Prior to TNL-3788, a bug would hide the edit button for the first response
postResponse(@view, 2)
expect(@view.$(".forum-response").length).toBe(2)
# Post should not be editable, responses should be
expect(@view.$(".post-actions-list").find(".action-edit").parent(".is-hidden").length).toBe(1)
expect(@view.$(".response-actions-list").find(".action-edit").parent().not(".is-hidden").length).toBe(2)
describe "response count and pagination", ->
it "correctly render for a thread with no responses", ->
renderWithTestResponses(@view, 0)
assertResponseCountAndPaginationCorrect(@view, "0 responses", null, null)
it "correctly render for a thread with one response", ->
renderWithTestResponses(@view, 1)
assertResponseCountAndPaginationCorrect(@view, "1 response", "Showing all responses", null)
it "correctly render for a thread with one additional page", ->
renderWithTestResponses(@view, 1, {resp_total: 2})
assertResponseCountAndPaginationCorrect(@view, "2 responses", "Showing first response", "Load all responses")
it "correctly render for a thread with multiple additional pages", ->
renderWithTestResponses(@view, 2, {resp_total: 111})
assertResponseCountAndPaginationCorrect(@view, "111 responses", "Showing first 2 responses", "Load next 100 responses")
describe "on clicking the load more button", ->
beforeEach ->
renderWithTestResponses(@view, 1, {resp_total: 5})
assertResponseCountAndPaginationCorrect(@view, "5 responses", "Showing first response", "Load all responses")
it "correctly re-render when all threads have loaded", ->
renderWithTestResponses(@view, 5, {resp_total: 5})
@view.$el.find(".load-response-button").click()
assertResponseCountAndPaginationCorrect(@view, "5 responses", "Showing all responses", null)
it "correctly re-render when one page remains", ->
renderWithTestResponses(@view, 3, {resp_total: 42})
@view.$el.find(".load-response-button").click()
assertResponseCountAndPaginationCorrect(@view, "42 responses", "Showing first 3 responses", "Load all responses")
it "correctly re-render when multiple pages remain", ->
renderWithTestResponses(@view, 3, {resp_total: 111})
@view.$el.find(".load-response-button").click()
assertResponseCountAndPaginationCorrect(@view, "111 responses", "Showing first 3 responses", "Load next 100 responses")
describe "inline mode", ->
beforeEach ->
@view = new DiscussionThreadView(
model: @thread
el: $("#fixture-element")
mode: "inline"
course_settings: DiscussionSpecHelper.makeCourseSettings()
)
describe "render", ->
it "shows content that should be visible when collapsed", ->
@view.render()
assertExpandedContentVisible(@view, false)
it "does not render any responses by default", ->
@view.render()
expect($.ajax).not.toHaveBeenCalled()
expect(@view.$el.find(".responses li").length).toEqual(0)
describe "focus", ->
it "sends focus to the conversation when opened", (done) ->
DiscussionViewSpecHelper.setNextResponseContent({resp_total: 0, children: []})
@view.render()
@view.expand()
self = @
jasmine.waitUntil(->
# This is the implementation of "toBeFocused". However, simply calling that method
# with no wait seems to be flaky.
article = self.view.$el.find('.discussion-article')
return article[0] == article[0].ownerDocument.activeElement
).then ->
done()
describe "expand/collapse", ->
it "shows/hides appropriate content", ->
DiscussionViewSpecHelper.setNextResponseContent({resp_total: 0, children: []})
@view.render()
@view.expand()
assertExpandedContentVisible(@view, true)
@view.collapse()
assertExpandedContentVisible(@view, false)
it "switches between the abbreviated and full body", ->
DiscussionViewSpecHelper.setNextResponseContent({resp_total: 0, children: []})
longBody = new Array(100).join("test ")
expectedAbbreviation = DiscussionUtil.abbreviateString(longBody, 140)
@thread.set("body", longBody)
@view.render()
expect($(".post-body").text()).toEqual(expectedAbbreviation)
expect(DiscussionThreadShowView.prototype.convertMath).toHaveBeenCalled()
DiscussionThreadShowView.prototype.convertMath.calls.reset()
@view.expand()
expect($(".post-body").text()).toEqual(longBody)
expect(DiscussionThreadShowView.prototype.convertMath).toHaveBeenCalled()
DiscussionThreadShowView.prototype.convertMath.calls.reset()
@view.collapse()
expect($(".post-body").text()).toEqual(expectedAbbreviation)
expect(DiscussionThreadShowView.prototype.convertMath).toHaveBeenCalled()
it "strips script tags appropriately", ->
DiscussionViewSpecHelper.setNextResponseContent({resp_total: 0, children: []})
longMaliciousBody = new Array(100).join("<script>alert('Until they think warm days will never cease');</script>\n")
@thread.set("body", longMaliciousBody)
maliciousAbbreviation = DiscussionUtil.abbreviateString(@thread.get('body'), 140)
# The nodes' html should be different than the strings, but
# their texts should be the same, indicating that they've been
# properly escaped. To be safe, make sure the string "<script"
# isn't present, either
@view.render()
expect($(".post-body").html()).not.toEqual(maliciousAbbreviation)
expect($(".post-body").text()).toEqual(maliciousAbbreviation)
expect($(".post-body").html()).not.toContain("<script")
@view.expand()
expect($(".post-body").html()).not.toEqual(longMaliciousBody)
expect($(".post-body").text()).toEqual(longMaliciousBody)
expect($(".post-body").html()).not.toContain("<script")
@view.collapse()
expect($(".post-body").html()).not.toEqual(maliciousAbbreviation)
expect($(".post-body").text()).toEqual(maliciousAbbreviation)
expect($(".post-body").html()).not.toContain("<script")
it "re-renders the show view correctly when leaving the edit view", ->
DiscussionViewSpecHelper.setNextResponseContent({resp_total: 0, children: []})
@view.render()
@view.expand()
assertExpandedContentVisible(@view, true)
@view.edit()
assertContentVisible(@view, ".edit-post-body", true)
expect(@view.$el.find(".post-actions-list").length).toBe(0)
@view.closeEditView(DiscussionSpecHelper.makeEventSpy())
expect(@view.$el.find(".edit-post-body").length).toBe(0)
assertContentVisible(@view, ".post-actions-list", true)
describe "for question threads", ->
beforeEach ->
@thread.set("thread_type", "question")
@view = new DiscussionThreadView(
model: @thread
el: $("#fixture-element")
mode: "tab"
course_settings: DiscussionSpecHelper.makeCourseSettings()
)
generateContent = (idStart, idEnd) ->
_.map(_.range(idStart, idEnd), (i) -> createTestResponseJson(i))
renderTestCase = (view, numEndorsed, numNonEndorsed) ->
renderWithContent(
view,
{
endorsed_responses: generateContent(0, numEndorsed),
non_endorsed_responses: generateContent(numEndorsed, numEndorsed + numNonEndorsed),
non_endorsed_resp_total: numNonEndorsed
}
)
expect(view.$(".js-marked-answer-list .discussion-response").length).toEqual(numEndorsed)
expect(view.$(".js-response-list .discussion-response").length).toEqual(numNonEndorsed)
assertResponseCountAndPaginationCorrect(
view,
"#{numNonEndorsed} #{if numEndorsed then "other " else ""}#{if numNonEndorsed == 1 then "response" else "responses"}",
if numNonEndorsed then "Showing all responses" else null,
null
)
_.each({"no": 0, "one": 1, "many": 5}, (numEndorsed, endorsedDesc) ->
_.each({"no": 0, "one": 1, "many": 5}, (numNonEndorsed, nonEndorsedDesc) ->
it "renders correctly with #{endorsedDesc} marked answer(s) and #{nonEndorsedDesc} response(s)", ->
renderTestCase(@view, numEndorsed, numNonEndorsed)
)
)
it "handles pagination correctly", ->
renderWithContent(
@view,
{
endorsed_responses: generateContent(0, 2),
non_endorsed_responses: generateContent(3, 6),
non_endorsed_resp_total: 42
}
)
DiscussionViewSpecHelper.setNextResponseContent({
# Add an endorsed response; it should be rendered
endorsed_responses: generateContent(0, 3),
non_endorsed_responses: generateContent(6, 9),
non_endorsed_resp_total: 41
})
@view.$el.find(".load-response-button").click()
expect($(".js-marked-answer-list .discussion-response").length).toEqual(3)
expect($(".js-response-list .discussion-response").length).toEqual(6)
assertResponseCountAndPaginationCorrect(
@view,
"41 other responses",
"Showing first 6 responses",
"Load all responses"
)
describe "post restrictions", ->
beforeEach ->
@thread.attributes.ability = _.extend(@thread.attributes.ability, {
can_report: false
can_vote: false
})
@view = new DiscussionThreadView(
model: @thread
el: $("#fixture-element")
mode: "tab"
course_settings: DiscussionSpecHelper.makeCourseSettings()
)
it "doesn't show report option if can_report ability is disabled", ->
@view.render()
expect(@view.$el.find(".action-report").closest(".actions-item")).toHaveClass('is-hidden')
it "doesn't show voting button if can_vote ability is disabled", ->
@view.render()
expect(@view.$el.find(".action-vote").closest(".actions-item")).toHaveClass('is-hidden')

View File

@@ -0,0 +1,104 @@
(function() {
'use strict';
describe('DiscussionTopicMenuView', function() {
beforeEach(function() {
this.createTopicView = function (options) {
options = _.extend({
course_settings: this.course_settings,
topicId: void 0
}, options);
this.view = new DiscussionTopicMenuView(options);
this.view.render().appendTo('#fixture-element');
this.defaultTextWidth = this.completeText.length;
};
this.openMenu = function () {
var menuWrapper = this.view.$('.topic-menu-wrapper');
expect(menuWrapper).toBeHidden();
this.view.$el.find('.post-topic-button').first().click();
expect(menuWrapper).toBeVisible();
};
this.closeMenu = function () {
var menuWrapper = this.view.$('.topic-menu-wrapper');
expect(menuWrapper).toBeVisible();
this.view.$el.find('.post-topic-button').first().click();
expect(menuWrapper).toBeHidden();
};
DiscussionSpecHelper.setUpGlobals();
DiscussionSpecHelper.setUnderscoreFixtures();
this.course_settings = new DiscussionCourseSettings({
'category_map': {
'subcategories': {
'Basic Question Types': {
'subcategories': {},
'children': ['Selection From Options', 'Numerical Input'],
'entries': {
'Selection From Options': {
'sort_key': null,
'is_cohorted': true,
'id': 'cba3e4cd91d0466b9ac50926e495b76f'
},
'Numerical Input': {
'sort_key': null,
'is_cohorted': false,
'id': 'c49f0dfb8fc94c9c8d9999cc95190c56'
}
}
}
},
'children': ['Basic Question Types'],
'entries': {}
},
'is_cohorted': true
});
this.parentCategoryText = 'Basic Question Types';
this.selectedOptionText = 'Selection From Options';
this.completeText = this.parentCategoryText + ' / ' + this.selectedOptionText;
});
it('completely show parent category and sub-category', function() {
var dropdownText;
this.createTopicView();
this.view.maxNameWidth = this.defaultTextWidth + 1;
this.view.$el.find('a.topic-title').first().click();
dropdownText = this.view.$el.find('.js-selected-topic').text();
expect(this.completeText).toEqual(dropdownText);
});
it('broken span doesn\'t occur', function() {
var dropdownText;
this.createTopicView();
this.view.maxNameWidth = this.selectedOptionText.length + 100;
this.view.$el.find('a.topic-title').first().click();
dropdownText = this.view.$el.find('.js-selected-topic').text();
expect(dropdownText.indexOf('/ span>')).toEqual(-1);
});
it('appropriate topic is selected if `topicId` is passed', function () {
var completeText = this.parentCategoryText + ' / Numerical Input',
dropdownText;
this.createTopicView({
topicId: 'c49f0dfb8fc94c9c8d9999cc95190c56'
});
this.view.maxNameWidth = this.defaultTextWidth + 1;
this.view.render();
dropdownText = this.view.$el.find('.js-selected-topic').text();
expect(completeText).toEqual(dropdownText);
});
it('click outside of the dropdown close it', function () {
this.createTopicView();
this.openMenu();
$(document.body).click();
expect(this.view.$('.topic-menu-wrapper')).toBeHidden();
});
it('can toggle the menu', function () {
this.createTopicView();
this.openMenu();
this.closeMenu();
});
});
}).call(this);

View File

@@ -0,0 +1,221 @@
describe "DiscussionUserProfileView", ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
DiscussionSpecHelper.setUnderscoreFixtures()
spyOn(DiscussionThreadProfileView.prototype, "render")
makeThreads = (numThreads) ->
_.map(_.range(numThreads), (i) -> {id: i.toString(), body: "dummy body"})
makeView = (threads, page, numPages) ->
new DiscussionUserProfileView(
collection: threads
page: page
numPages: numPages
)
describe "thread rendering should be correct", ->
checkRender = (numThreads) ->
threads = makeThreads(numThreads)
view = makeView(threads, 1, 1)
expect(view.$(".discussion").children().length).toEqual(numThreads)
_.each(threads, (thread) -> expect(view.$("#thread_#{thread.id}").length).toEqual(1))
it "with no threads", ->
checkRender(0)
it "with one thread", ->
checkRender(1)
it "with several threads", ->
checkRender(5)
describe "pagination rendering should be correct", ->
baseUri = URI(window.location)
pageInfo = (page) -> {url: baseUri.clone().addSearch("page", page).toString(), number: page}
checkRender = (params) ->
view = makeView([], params.page, params.numPages)
paginator = view.$(".discussion-paginator")
expect(paginator.find(".current-page").text()).toEqual(params["page"].toString())
expect(paginator.find(".first-page").length).toBe(if params["first"] then 1 else 0);
expect(paginator.find(".previous-page").length).toBe(if params["previous"] then 1 else 0);
expect(paginator.find(".previous-ellipses").length).toBe(if params["leftdots"] then 1 else 0);
expect(paginator.find(".next-page").length).toBe(if params["next"] then 1 else 0);
expect(paginator.find(".next-ellipses").length).toBe(if params["rightdots"] then 1 else 0);
expect(paginator.find(".last-page").length).toBe(if params["last"] then 1 else 0);
get_page_number = (element) => parseInt($(element).text())
expect(_.map(paginator.find(".lower-page a"), get_page_number)).toEqual(params["lowPages"])
expect(_.map(paginator.find(".higher-page a"), get_page_number)).toEqual(params["highPages"])
it "for one page", ->
checkRender(
page: 1
numPages: 1
previous: null
first: null
leftdots: false
lowPages: []
highPages: []
rightdots: false
last: null
next: null
)
it "for first page of three (max with no last)", ->
checkRender(
page: 1
numPages: 3
previous: null
first: null
leftdots: false
lowPages: []
highPages: [2, 3]
rightdots: false
last: null
next: 2
)
it "for first page of four (has last but no dots)", ->
checkRender(
page: 1
numPages: 4
previous: null
first: null
leftdots: false
lowPages: []
highPages: [2, 3]
rightdots: false
last: 4
next: 2
)
it "for first page of five (has dots)", ->
checkRender(
page: 1
numPages: 5
previous: null
first: null
leftdots: false
lowPages: []
highPages: [2, 3]
rightdots: true
last: 5
next: 2
)
it "for last page of three (max with no first)", ->
checkRender(
page: 3
numPages: 3
previous: 2
first: null
leftdots: false
lowPages: [1, 2]
highPages: []
rightdots: false
last: null
next: null
)
it "for last page of four (has first but no dots)", ->
checkRender(
page: 4
numPages: 4
previous: 3
first: 1
leftdots: false
lowPages: [2, 3]
highPages: []
rightdots: false
last: null
next: null
)
it "for last page of five (has dots)", ->
checkRender(
page: 5
numPages: 5
previous: 4
first: 1
leftdots: true
lowPages: [3, 4]
highPages: []
rightdots: false
last: null
next: null
)
it "for middle page of five (max with no first/last)", ->
checkRender(
page: 3
numPages: 5
previous: 2
first: null
leftdots: false
lowPages: [1, 2]
highPages: [4, 5]
rightdots: false
last: null
next: 4
)
it "for middle page of seven (has first/last but no dots)", ->
checkRender(
page: 4
numPages: 7
previous: 3
first: 1
leftdots: false
lowPages: [2, 3]
highPages: [5, 6]
rightdots: false
last: 7
next: 5
)
it "for middle page of nine (has dots)", ->
checkRender(
page: 5
numPages: 9
previous: 4
first: 1
leftdots: true
lowPages: [3, 4]
highPages: [6, 7]
rightdots: true
last: 9
next: 6
)
describe "pagination interaction", ->
beforeEach ->
@view = makeView(makeThreads(3), 1, 2)
deferred = $.Deferred();
spyOn($, "ajax").and.returnValue(deferred);
it "causes updated rendering", ->
$.ajax.and.callFake(
(params) =>
params.success(
discussion_data: [{id: "on_page_42", body: "dummy body"}]
page: 42
num_pages: 99
)
{always: ->}
)
@view.$(".discussion-pagination a").first().click()
expect(@view.$(".current-page").text()).toEqual("42")
expect(@view.$(".last-page").text()).toEqual("99")
it "handles AJAX errors", ->
spyOn(DiscussionUtil, "discussionAlert")
$.ajax.and.callFake(
(params) =>
params.error()
{always: ->}
)
@view.$(".discussion-pagination a").first().click()
expect(DiscussionUtil.discussionAlert).toHaveBeenCalled()

View File

@@ -0,0 +1,98 @@
class @DiscussionViewSpecHelper
@makeThreadWithProps = (props) ->
# Minimal set of properties necessary for rendering
thread = {
id: "dummy_id",
thread_type: "discussion",
pinned: false,
endorsed: false,
votes: {up_count: '0'},
read: false,
unread_comments_count: 0,
comments_count: 0,
abuse_flaggers: [],
body: "",
title: "dummy title",
created_at: "2014-08-18T01:02:03Z"
ability: {
can_delete: false,
can_reply: true,
can_vote: false,
editable: false,
}
}
$.extend(thread, props)
@checkVoteClasses = (view) ->
view.render()
display_button = view.$el.find(".display-vote")
expect(display_button.hasClass("is-hidden")).toBe(true)
action_button = view.$el.find(".action-vote")
# Check that inline css is not applied to the ".action-vote"
expect(action_button).not.toHaveAttr('style','display: inline; ');
@expectVoteRendered = (view, model, user) ->
button = view.$el.find(".action-vote")
expect(button.hasClass("is-checked")).toBe(user.voted(model))
expect(button.attr("aria-checked")).toEqual(user.voted(model).toString())
expect(button.find(".vote-count").text()).toMatch("^#{model.get('votes').up_count} Votes?$")
expect(button.find(".sr.js-sr-vote-count").text()).toMatch("^there are currently #{model.get('votes').up_count} votes?$")
@checkRenderVote = (view, model) ->
view.render()
DiscussionViewSpecHelper.expectVoteRendered(view, model, window.user)
window.user.vote(model)
view.render()
DiscussionViewSpecHelper.expectVoteRendered(view, model, window.user)
window.user.unvote(model)
view.render()
DiscussionViewSpecHelper.expectVoteRendered(view, model, window.user)
triggerVoteEvent = (view, event, expectedUrl) ->
deferred = $.Deferred()
spyOn($, "ajax").and.callFake((params) =>
expect(params.url.toString()).toEqual(expectedUrl)
return deferred
)
view.render()
view.$el.find(".action-vote").trigger(event)
expect($.ajax).toHaveBeenCalled()
deferred.resolve()
@checkUpvote = (view, model, user, event) ->
expect(model.id in user.get('upvoted_ids')).toBe(false)
initialVoteCount = model.get('votes').up_count
triggerVoteEvent(view, event, DiscussionUtil.urlFor("upvote_#{model.get('type')}", model.id) + "?ajax=1")
expect(model.id in user.get('upvoted_ids')).toBe(true)
expect(model.get('votes').up_count).toEqual(initialVoteCount + 1)
@checkUnvote = (view, model, user, event) ->
user.vote(model)
expect(model.id in user.get('upvoted_ids')).toBe(true)
initialVoteCount = model.get('votes').up_count
triggerVoteEvent(view, event, DiscussionUtil.urlFor("undo_vote_for_#{model.get('type')}", model.id) + "?ajax=1")
expect(user.get('upvoted_ids')).toEqual([])
expect(model.get('votes').up_count).toEqual(initialVoteCount - 1)
@checkButtonEvents = (view, viewFunc, buttonSelector) ->
spy = spyOn(view, viewFunc)
button = view.$el.find(buttonSelector)
button.click()
expect(spy).toHaveBeenCalled()
spy.calls.reset()
button.trigger($.Event("keydown", {which: 13}))
expect(spy).not.toHaveBeenCalled()
spy.calls.reset()
button.trigger($.Event("keydown", {which: 32}))
expect(spy).toHaveBeenCalled()
@checkVoteButtonEvents = (view) ->
@checkButtonEvents(view, "toggleVote", ".action-vote")
@setNextResponseContent = (content) ->
$.ajax.and.callFake(
(params) =>
params.success({"content": content})
{always: ->}
)

View File

@@ -0,0 +1,229 @@
# -*- coding: utf-8 -*-
describe "NewPostView", ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
DiscussionSpecHelper.setUnderscoreFixtures()
window.$$course_id = "edX/999/test"
spyOn(DiscussionUtil, "makeWmdEditor").and.callFake(
($content, $local, cls_identifier) ->
$local("." + cls_identifier).html("<textarea></textarea>")
)
@discussion = new Discussion([], {pages: 1})
checkVisibility = (view, expectedVisible, expectedDisabled, render) =>
if render
view.render()
# Can also be undefined if the element does not exist.
expect(view.$('.group-selector-wrapper').is(":visible") or false).toEqual(expectedVisible)
disabled = view.$(".js-group-select").prop("disabled") or false
group_disabled = view.$('.group-selector-wrapper').hasClass('disabled')
if expectedVisible and !expectedDisabled
expect(disabled).toEqual(false)
expect(group_disabled).toEqual(false)
else if expectedDisabled
expect(disabled).toEqual(true)
expect(group_disabled).toEqual(true)
describe "cohort selector", ->
beforeEach ->
@course_settings = new DiscussionCourseSettings({
"category_map": {
"children": ["Topic", "General"],
"entries": {
"Topic": {"is_cohorted": true, "id": "topic"},
"General": {"is_cohorted": false, "id": "general"}
}
},
"allow_anonymous": false,
"allow_anonymous_to_peers": false,
"is_cohorted": true,
"cohorts": [
{"id": 1, "name": "Cohort1"},
{"id": 2, "name": "Cohort2"}
]
})
@view = new NewPostView(
el: $("#fixture-element"),
collection: @discussion,
course_settings: @course_settings,
is_commententable_cohorted: true,
mode: "tab"
)
it "is not visible to students", ->
checkVisibility(@view, false, false, true)
it "allows TAs to see the cohort selector", ->
DiscussionSpecHelper.makeTA()
checkVisibility(@view, true, false, true)
it "allows moderators to see the cohort selector", ->
DiscussionSpecHelper.makeModerator()
checkVisibility(@view, true, false, true)
it "only enables the cohort selector when applicable", ->
DiscussionSpecHelper.makeModerator()
# We start on the cohorted discussion
checkVisibility(@view, true, false, true)
# Select the uncohorted topic
$('.topic-title:contains(General)').click()
# The menu should now be visible but disabled.
checkVisibility(@view, true, true, false)
# Select the cohorted topic again
$('.topic-title:contains(Topic)').click()
# It should be visible and enabled once more.
checkVisibility(@view, true, false, false)
it "allows the user to make a cohort selection", ->
DiscussionSpecHelper.makeModerator()
@view.render()
expectedGroupId = null
DiscussionSpecHelper.makeAjaxSpy(
(params) -> expect(params.data.group_id).toEqual(expectedGroupId)
)
_.each(
["1", "2", ""],
(groupIdStr) =>
expectedGroupId = groupIdStr
@view.$(".js-group-select").val(groupIdStr)
@view.$(".js-post-title").val("dummy title")
@view.$(".js-post-body textarea").val("dummy body")
@view.$(".forum-new-post-form").submit()
expect($.ajax).toHaveBeenCalled()
$.ajax.calls.reset()
)
describe "always cohort inline discussions ", ->
beforeEach ->
@course_settings = new DiscussionCourseSettings({
"category_map": {
"children": [],
"entries": {}
},
"allow_anonymous": false,
"allow_anonymous_to_peers": false,
"is_cohorted": true,
"cohorts": [
{"id": 1, "name": "Cohort1"},
{"id": 2, "name": "Cohort2"}
]
})
@view = new NewPostView(
el: $("#fixture-element"),
collection: @discussion,
course_settings: @course_settings,
mode: "tab"
)
it "disables the cohort menu if it is set false", ->
DiscussionSpecHelper.makeModerator()
@view.is_commentable_cohorted = false
checkVisibility(@view, true, true, true)
it "enables the cohort menu if it is set true", ->
DiscussionSpecHelper.makeModerator()
@view.is_commentable_cohorted = true
checkVisibility(@view, true, false, true)
it "is not visible to students when set false", ->
@view.is_commentable_cohorted = false
checkVisibility(@view, false, false, true)
it "is not visible to students when set true", ->
@view.is_commentable_cohorted = true
checkVisibility(@view, false, false, true)
describe "cancel post resets form ", ->
beforeEach ->
@course_settings = new DiscussionCourseSettings({
"allow_anonymous_to_peers":true,
"allow_anonymous":true,
"category_map": {
"subcategories": {
"Week 1": {
"subcategories": {},
"children": [
"Topic-Level Student-Visible Label"
],
"entries": {
"Topic-Level Student-Visible Label": {
"sort_key": null,
"is_cohorted": false,
"id": "2b3a858d0c884eb4b272dbbe3f2ffddd"
}
}
}
},
"children": [
"General",
"Week 1"
],
"entries": {
"General": {
"sort_key": "General",
"is_cohorted": false,
"id": "i4x-waqastest-waqastest-course-waqastest"
}
}
}
})
checkPostCancelReset = (mode, discussion, course_settings) ->
view = new NewPostView(
el: $("#fixture-element"),
collection: discussion,
course_settings: course_settings,
mode: mode
)
view.render()
eventSpy = jasmine.createSpy('eventSpy')
view.listenTo(view, "newPost:cancel", eventSpy)
view.$(".post-errors").html("<li class='post-error'>Title can't be empty</li>")
view.$("label[for$='post-type-question']").click()
view.$(".js-post-title").val("Test Title")
view.$(".js-post-body textarea").val("Test body")
view.$(".wmd-preview p").html("Test body")
view.$(".js-follow").prop("checked", false)
view.$(".js-anon").prop("checked", true)
view.$(".js-anon-peers").prop("checked", true)
if mode == "tab"
view.$("a[data-discussion-id='2b3a858d0c884eb4b272dbbe3f2ffddd']").click()
view.$(".cancel").click()
expect(eventSpy).toHaveBeenCalled()
expect(view.$(".post-errors").html()).toEqual("");
expect($("input[id$='post-type-discussion']")).toBeChecked()
expect($("input[id$='post-type-question']")).not.toBeChecked()
expect(view.$(".js-post-title").val()).toEqual("");
expect(view.$(".js-post-body textarea").val()).toEqual("");
expect(view.$(".js-follow")).toBeChecked()
expect(view.$(".js-anon")).not.toBeChecked()
expect(view.$(".js-anon-peers")).not.toBeChecked()
if mode == "tab"
expect(view.$(".js-selected-topic").text()).toEqual("General")
_.each(["tab", "inline"], (mode) =>
it "resets the form in #{mode} mode", ->
checkPostCancelReset(mode, @discussion, @course_settings)
)
it "posts to the correct URL", ->
topicId = "test_topic"
spyOn($, "ajax").and.callFake(
(params) ->
expect(params.url.path()).toEqual(DiscussionUtil.urlFor("create_thread", topicId))
{always: ->}
)
view = new NewPostView(
el: $("#fixture-element"),
collection: @discussion,
course_settings: new DiscussionCourseSettings({
allow_anonymous: false,
allow_anonymous_to_peers: false
}),
mode: "inline",
topicId: topicId
)
view.render()
view.$(".forum-new-post-form").submit()
expect($.ajax).toHaveBeenCalled()

View File

@@ -0,0 +1,102 @@
describe 'ResponseCommentShowView', ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
# set up the container for the response to go in
DiscussionSpecHelper.setUnderscoreFixtures()
# set up a model for a new Comment
@comment = new Comment {
id: '01234567',
user_id: '567',
course_id: 'edX/999/test',
body: 'this is a response',
created_at: '2013-04-03T20:08:39Z',
abuse_flaggers: ['123']
roles: []
}
@view = new ResponseCommentShowView({ model: @comment })
spyOn(@view, "convertMath")
it 'defines the tag', ->
expect($('#jasmine-fixtures')).toExist
expect(@view.tagName).toBeDefined
expect(@view.el.tagName.toLowerCase()).toBe 'li'
it 'is tied to the model', ->
expect(@view.model).toBeDefined()
describe 'rendering', ->
beforeEach ->
spyOn(@view, 'renderAttrs')
it 'can be flagged for abuse', ->
@comment.flagAbuse()
expect(@comment.get 'abuse_flaggers').toEqual ['123', '567']
it 'can be unflagged for abuse', ->
temp_array = []
temp_array.push(window.user.get('id'))
@comment.set("abuse_flaggers",temp_array)
@comment.unflagAbuse()
expect(@comment.get 'abuse_flaggers').toEqual []
describe '_delete', ->
it 'triggers on the correct events', ->
DiscussionUtil.loadRoles []
@comment.updateInfo {ability: {'can_delete': true}}
@view.render()
DiscussionViewSpecHelper.checkButtonEvents(@view, "_delete", ".action-delete")
it 'triggers the delete event', ->
triggerTarget = jasmine.createSpy()
@view.bind "comment:_delete", triggerTarget
@view._delete()
expect(triggerTarget).toHaveBeenCalled()
describe 'edit', ->
it 'triggers on the correct events', ->
DiscussionUtil.loadRoles []
@comment.updateInfo {ability: {'can_edit': true}}
@view.render()
DiscussionViewSpecHelper.checkButtonEvents(@view, "edit", ".action-edit")
it 'triggers comment:edit when the edit button is clicked', ->
triggerTarget = jasmine.createSpy()
@view.bind "comment:edit", triggerTarget
@view.edit()
expect(triggerTarget).toHaveBeenCalled()
describe "labels", ->
expectOneElement = (view, selector, visible=true) =>
view.render()
elements = view.$el.find(selector)
expect(elements.length).toEqual(1)
if visible
expect(elements).not.toHaveClass("is-hidden")
else
expect(elements).toHaveClass("is-hidden")
it 'displays the reported label when appropriate for a non-staff user', ->
@comment.set('abuse_flaggers', [])
expectOneElement(@view, '.post-label-reported', false)
# flagged by current user - should be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id])
expectOneElement(@view, '.post-label-reported')
# flagged by some other user but not the current one - should not be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
expectOneElement(@view, '.post-label-reported', false)
it 'displays the reported label when appropriate for a flag moderator', ->
DiscussionSpecHelper.makeModerator()
@comment.set('abuse_flaggers', [])
expectOneElement(@view, '.post-label-reported', false)
# flagged by current user - should be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id])
expectOneElement(@view, '.post-label-reported')
# flagged by some other user but not the current one - should still be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
expectOneElement(@view, '.post-label-reported')

View File

@@ -0,0 +1,155 @@
describe 'ResponseCommentView', ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
@comment = new Comment {
id: '01234567',
user_id: user.id,
course_id: $$course_id,
body: 'this is a response',
created_at: '2013-04-03T20:08:39Z',
abuse_flaggers: ['123']
roles: ['Student']
}
DiscussionSpecHelper.setUnderscoreFixtures()
@view = new ResponseCommentView({ model: @comment, el: $("#fixture-element") })
spyOn(ResponseCommentShowView.prototype, "convertMath")
spyOn(DiscussionUtil, "makeWmdEditor")
@view.render()
describe '_delete', ->
beforeEach ->
@comment.updateInfo {ability: {can_delete: true}}
@event = DiscussionSpecHelper.makeEventSpy()
spyOn(@comment, "remove")
spyOn(@view.$el, "remove")
setAjaxResult = (isSuccess) ->
spyOn($, "ajax").and.callFake(
(params) =>
(if isSuccess then params.success else params.error) {}
{always: ->}
)
it 'requires confirmation before deleting', ->
spyOn(window, "confirm").and.returnValue(false)
setAjaxResult(true)
@view._delete(@event)
expect(window.confirm).toHaveBeenCalled()
expect($.ajax).not.toHaveBeenCalled()
expect(@comment.remove).not.toHaveBeenCalled()
it 'removes the deleted comment object', ->
setAjaxResult(true)
@view._delete(@event)
expect(@comment.remove).toHaveBeenCalled()
expect(@view.$el.remove).toHaveBeenCalled()
it 'calls the ajax comment deletion endpoint', ->
setAjaxResult(true)
@view._delete(@event)
expect(@event.preventDefault).toHaveBeenCalled()
expect($.ajax).toHaveBeenCalled()
expect($.ajax.calls.mostRecent().args[0].url._parts.path).toEqual('/courses/edX/999/test/discussion/comments/01234567/delete')
it 'handles ajax errors', ->
spyOn(DiscussionUtil, "discussionAlert")
setAjaxResult(false)
@view._delete(@event)
expect(@event.preventDefault).toHaveBeenCalled()
expect($.ajax).toHaveBeenCalled()
expect(@comment.remove).not.toHaveBeenCalled()
expect(@view.$el.remove).not.toHaveBeenCalled()
expect(DiscussionUtil.discussionAlert).toHaveBeenCalled()
it 'does not delete a comment if the permission is false', ->
@comment.updateInfo {ability: {'can_delete': false}}
spyOn(window, "confirm")
setAjaxResult(true)
@view._delete(@event)
expect(window.confirm).not.toHaveBeenCalled()
expect($.ajax).not.toHaveBeenCalled()
expect(@comment.remove).not.toHaveBeenCalled()
expect(@view.$el.remove).not.toHaveBeenCalled()
describe 'renderShowView', ->
it 'renders the show view, removes the edit view, and registers event handlers', ->
spyOn(@view, "_delete")
spyOn(@view, "edit")
# Without calling renderEditView first, renderShowView is a no-op
@view.renderEditView()
@view.renderShowView()
@view.showView.trigger "comment:_delete", DiscussionSpecHelper.makeEventSpy()
expect(@view._delete).toHaveBeenCalled()
@view.showView.trigger "comment:edit", DiscussionSpecHelper.makeEventSpy()
expect(@view.edit).toHaveBeenCalled()
expect(@view.$(".edit-post-form#comment_#{@comment.id}")).not.toHaveClass("edit-post-form")
describe 'renderEditView', ->
it 'renders the edit view, removes the show view, and registers event handlers', ->
spyOn(@view, "update")
spyOn(@view, "cancelEdit")
@view.renderEditView()
@view.editView.trigger "comment:update", DiscussionSpecHelper.makeEventSpy()
expect(@view.update).toHaveBeenCalled()
@view.editView.trigger "comment:cancel_edit", DiscussionSpecHelper.makeEventSpy()
expect(@view.cancelEdit).toHaveBeenCalled()
expect(@view.$(".edit-post-form#comment_#{@comment.id}")).toHaveClass("edit-post-form")
describe 'edit', ->
it 'triggers the appropriate event and switches to the edit view', ->
spyOn(@view, 'renderEditView')
editTarget = jasmine.createSpy()
@view.bind "comment:edit", editTarget
@view.edit()
expect(@view.renderEditView).toHaveBeenCalled()
expect(editTarget).toHaveBeenCalled()
describe 'with edit view displayed', ->
beforeEach ->
@view.renderEditView()
describe 'cancelEdit', ->
it 'triggers the appropriate event and switches to the show view', ->
spyOn(@view, 'renderShowView')
cancelEditTarget = jasmine.createSpy()
@view.bind "comment:cancel_edit", cancelEditTarget
@view.cancelEdit()
expect(@view.renderShowView).toHaveBeenCalled()
expect(cancelEditTarget).toHaveBeenCalled()
describe 'update', ->
beforeEach ->
@updatedBody = "updated body"
# Markdown code creates the editor, so we simulate that here
@view.$el.find(".edit-comment-body").html($("<textarea></textarea>"))
@view.$el.find(".edit-comment-body textarea").val(@updatedBody)
spyOn(@view, 'cancelEdit')
spyOn($, "ajax").and.callFake(
(params) =>
if @ajaxSucceed
params.success()
else
params.error({status: 500})
{always: ->}
)
it 'calls the update endpoint correctly and displays the show view on success', ->
@ajaxSucceed = true
@view.update(DiscussionSpecHelper.makeEventSpy())
expect($.ajax).toHaveBeenCalled()
expect($.ajax.calls.mostRecent().args[0].url._parts.path).toEqual('/courses/edX/999/test/discussion/comments/01234567/update')
expect($.ajax.calls.mostRecent().args[0].data.body).toEqual(@updatedBody)
expect(@view.model.get("body")).toEqual(@updatedBody)
expect(@view.cancelEdit).toHaveBeenCalled()
it 'handles AJAX errors', ->
originalBody = @comment.get("body")
@ajaxSucceed = false
@view.update(DiscussionSpecHelper.makeEventSpy())
expect($.ajax).toHaveBeenCalled()
expect($.ajax.calls.mostRecent().args[0].url._parts.path).toEqual('/courses/edX/999/test/discussion/comments/01234567/update')
expect($.ajax.calls.mostRecent().args[0].data.body).toEqual(@updatedBody)
expect(@view.model.get("body")).toEqual(originalBody)
expect(@view.cancelEdit).not.toHaveBeenCalled()
expect(@view.$(".edit-comment-form-errors *").length).toEqual(1)

View File

@@ -0,0 +1,230 @@
describe "ThreadResponseShowView", ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
DiscussionSpecHelper.setUnderscoreFixtures()
@user = DiscussionUtil.getUser()
@thread = new Thread({"thread_type": "discussion"})
@commentData = {
id: "dummy",
user_id: "567",
course_id: "TestOrg/TestCourse/TestRun",
body: "this is a comment",
created_at: "2013-04-03T20:08:39Z",
endorsed: false,
abuse_flaggers: [],
votes: {up_count: 42},
type: "comment"
}
@comment = new Comment(@commentData)
@comment.set("thread", @thread)
@view = new ThreadResponseShowView({ model: @comment, $el: $("#fixture-element") })
# Avoid unnecessary boilerplate
spyOn(ThreadResponseShowView.prototype, "convertMath")
@view.render()
describe "voting", ->
it "renders the vote state correctly", ->
DiscussionViewSpecHelper.checkRenderVote(@view, @comment)
it "check the vote classes after renders", ->
DiscussionViewSpecHelper.checkVoteClasses(@view)
it "votes correctly via click", ->
DiscussionViewSpecHelper.checkUpvote(@view, @comment, @user, $.Event("click"))
it "votes correctly via spacebar", ->
DiscussionViewSpecHelper.checkUpvote(@view, @comment, @user, $.Event("keydown", {which: 32}))
it "unvotes correctly via click", ->
DiscussionViewSpecHelper.checkUnvote(@view, @comment, @user, $.Event("click"))
it "unvotes correctly via spacebar", ->
DiscussionViewSpecHelper.checkUnvote(@view, @comment, @user, $.Event("keydown", {which: 32}))
it "renders endorsement correctly for a marked answer in a question thread", ->
endorsement = {
"username": "test_endorser",
"user_id": "test_id",
"time": new Date().toISOString()
}
@thread.set("thread_type", "question")
@comment.set({
"endorsed": true,
"endorsement": endorsement
})
@view.render()
expect(@view.$(".posted-details").text().replace(/\s+/g, " ")).toMatch(
"marked as answer less than a minute ago by " + endorsement.username
)
expect(@view.$(".posted-details > a").attr('href')).toEqual("/courses/edX/999/test/discussion/forum/users/test_id")
it "renders anonymous endorsement correctly for a marked answer in a question thread", ->
endorsement = {
"username": null,
"time": new Date().toISOString()
}
@thread.set("thread_type", "question")
@comment.set({
"endorsed": true,
"endorsement": endorsement
})
@view.render()
expect(@view.$(".posted-details").text()).toMatch("marked as answer less than a minute ago")
expect(@view.$(".posted-details").text()).not.toMatch("\sby\s")
it "renders endorsement correctly for an endorsed response in a discussion thread", ->
endorsement = {
"username": "test_endorser",
"user_id": "test_id",
"time": new Date().toISOString()
}
@thread.set("thread_type", "discussion")
@comment.set({
"endorsed": true,
"endorsement": endorsement
})
@view.render()
expect(@view.$(".posted-details").text().replace(/\s+/g, " ")).toMatch(
"endorsed less than a minute ago by " + endorsement.username
)
expect(@view.$(".posted-details > a").attr('href')).toEqual("/courses/edX/999/test/discussion/forum/users/test_id")
it "renders anonymous endorsement correctly for an endorsed response in a discussion thread", ->
endorsement = {
"username": null,
"time": new Date().toISOString()
}
@thread.set("thread_type", "discussion")
@comment.set({
"endorsed": true,
"endorsement": endorsement
})
@view.render()
expect(@view.$(".posted-details").text()).toMatch("endorsed less than a minute ago")
expect(@view.$(".posted-details").text()).not.toMatch("\sby\s")
it "re-renders correctly when endorsement changes", ->
spyOn($, "ajax").and.returnValue($.Deferred())
DiscussionUtil.loadRoles({"Moderator": [parseInt(window.user.id)]})
@thread.set("thread_type", "question")
@view.render()
expect(@view.$(".posted-details").text()).not.toMatch("marked as answer")
@view.$(".action-answer").click()
expect(@view.$(".posted-details").text()).toMatch("marked as answer")
@view.$(".action-answer").click()
expect(@view.$(".posted-details").text()).not.toMatch("marked as answer")
it "allows a moderator to mark an answer in a question thread", ->
spyOn($, "ajax").and.returnValue($.Deferred())
DiscussionUtil.loadRoles({"Moderator": [parseInt(window.user.id)]})
@thread.set({
"thread_type": "question",
"user_id": (parseInt(window.user.id) + 1).toString()
})
@view.render()
endorseButton = @view.$(".action-answer")
expect(endorseButton.length).toEqual(1)
expect(endorseButton.closest(".actions-item")).not.toHaveClass("is-hidden")
endorseButton.click()
expect(endorseButton).toHaveClass("is-checked")
it "allows the author of a question thread to mark an answer", ->
spyOn($, "ajax").and.returnValue($.Deferred())
@thread.set({
"thread_type": "question",
"user_id": window.user.id
})
@view.render()
endorseButton = @view.$(".action-answer")
expect(endorseButton.length).toEqual(1)
expect(endorseButton.closest(".actions-item")).not.toHaveClass("is-hidden")
endorseButton.click()
expect(endorseButton).toHaveClass("is-checked")
it "does not allow the author of a discussion thread to endorse", ->
@thread.set({
"thread_type": "discussion",
"user_id": window.user.id
})
@view.render()
endorseButton = @view.$(".action-endorse")
expect(endorseButton.length).toEqual(1)
expect(endorseButton.closest(".actions-item")).toHaveClass("is-hidden")
it "does not allow a student who is not the author of a question thread to mark an answer", ->
@thread.set({
"thread_type": "question",
"user_id": (parseInt(window.user.id) + 1).toString()
})
@view.render()
endorseButton = @view.$(".action-answer")
expect(endorseButton.length).toEqual(1)
expect(endorseButton.closest(".actions-item")).toHaveClass("is-hidden")
describe "labels", ->
expectOneElement = (view, selector, visible=true) =>
view.render()
elements = view.$el.find(selector)
expect(elements.length).toEqual(1)
if visible
expect(elements).not.toHaveClass("is-hidden")
else
expect(elements).toHaveClass("is-hidden")
it 'displays the reported label when appropriate for a non-staff user', ->
expectOneElement(@view, '.post-label-reported', false)
# flagged by current user - should be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id])
expectOneElement(@view, '.post-label-reported')
# flagged by some other user but not the current one - should not be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
expectOneElement(@view, '.post-label-reported', false)
it 'displays the reported label when appropriate for a flag moderator', ->
DiscussionSpecHelper.makeModerator()
expectOneElement(@view, '.post-label-reported', false)
# flagged by current user - should be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id])
expectOneElement(@view, '.post-label-reported')
# flagged by some other user but not the current one - should still be labelled
@comment.set('abuse_flaggers', [DiscussionUtil.getUser().id + 1])
expectOneElement(@view, '.post-label-reported')
describe "endorser display", ->
beforeEach ->
@comment.set('endorsement', {
"username": "test_endorser",
"time": new Date().toISOString()
})
spyOn(DiscussionUtil, 'urlFor').and.returnValue('test_endorser_url')
checkUserLink = (element, is_ta, is_staff) ->
expect(element.find('a.username').length).toEqual(1)
expect(element.find('a.username').text()).toEqual('test_endorser')
expect(element.find('a.username').attr('href')).toEqual('test_endorser_url')
expect(element.find('.user-label-community-ta').length).toEqual(if is_ta then 1 else 0)
expect(element.find('.user-label-staff').length).toEqual(if is_staff then 1 else 0)
it "renders nothing when the response has not been endorsed", ->
@comment.set('endorsement', null)
expect(@view.getEndorserDisplay()).toBeNull()
it "renders correctly for a student-endorsed response", ->
$el = $('#fixture-element').html(@view.getEndorserDisplay())
checkUserLink($el, false, false)
it "renders correctly for a community TA-endorsed response", ->
spyOn(DiscussionUtil, 'isTA').and.returnValue(true)
$el = $('#fixture-element').html(@view.getEndorserDisplay())
checkUserLink($el, true, false)
it "renders correctly for a staff-endorsed response", ->
spyOn(DiscussionUtil, 'isStaff').and.returnValue(true)
$el = $('#fixture-element').html(@view.getEndorserDisplay())
checkUserLink($el, false, true)

View File

@@ -0,0 +1,92 @@
describe 'ThreadResponseView', ->
beforeEach ->
DiscussionSpecHelper.setUpGlobals()
DiscussionSpecHelper.setUnderscoreFixtures()
@thread = new Thread({"thread_type": "discussion"})
@response = new Comment {
children: [{}, {}],
thread: @thread,
}
@view = new ThreadResponseView({model: @response, el: $("#fixture-element")})
spyOn(ThreadResponseShowView.prototype, "render")
spyOn(ResponseCommentView.prototype, "render")
describe 'closed and open Threads', ->
checkCommentForm = (closed) ->
thread = new Thread({"thread_type": "discussion", "closed": closed})
commentData = {
id: "dummy",
user_id: "567",
course_id: "TestOrg/TestCourse/TestRun",
body: "this is a comment",
created_at: "2013-04-03T20:08:39Z",
abuse_flaggers: [],
type: "comment",
children: [],
thread: thread,
}
comment = new Comment(commentData)
view = new ThreadResponseView({
model: comment, el: $("#fixture-element"),
})
view.render()
expect(view.$('.comment-form').closest('li').is(":visible")).toBe(not closed)
it 'hides comment form when thread is closed', ->
checkCommentForm(true)
it 'show comment form when thread is open', ->
checkCommentForm(false)
describe 'renderComments', ->
it 'hides "show comments" link if collapseComments is not set', ->
@view.render()
expect(@view.$(".comments")).toBeVisible()
expect(@view.$(".action-show-comments")).not.toBeVisible()
it 'hides "show comments" link if collapseComments is set but response has no comments', ->
@response = new Comment { children: [], thread: @thread }
@view = new ThreadResponseView({
model: @response, el: $("#fixture-element"),
collapseComments: true
})
@view.render()
expect(@view.$(".comments")).toBeVisible()
expect(@view.$(".action-show-comments")).not.toBeVisible()
it 'hides comments if collapseComments is set and shows them when "show comments" link is clicked', ->
@view = new ThreadResponseView({
model: @response, el: $("#fixture-element"),
collapseComments: true
})
@view.render()
expect(@view.$(".comments")).not.toBeVisible()
expect(@view.$(".action-show-comments")).toBeVisible()
@view.$(".action-show-comments").click()
expect(@view.$(".comments")).toBeVisible()
expect(@view.$(".action-show-comments")).not.toBeVisible()
it 'populates commentViews and binds events', ->
# Ensure that edit view is set to test invocation of cancelEdit
@view.createEditView()
spyOn(@view, 'cancelEdit')
spyOn(@view, 'cancelCommentEdits')
spyOn(@view, 'hideCommentForm')
spyOn(@view, 'showCommentForm')
@view.renderComments()
expect(@view.commentViews.length).toEqual(2)
@view.commentViews[0].trigger "comment:edit", jasmine.createSpyObj("event", ["preventDefault"])
expect(@view.cancelEdit).toHaveBeenCalled()
expect(@view.cancelCommentEdits).toHaveBeenCalled()
expect(@view.hideCommentForm).toHaveBeenCalled()
@view.commentViews[0].trigger "comment:cancel_edit"
expect(@view.showCommentForm).toHaveBeenCalled()
describe 'cancelCommentEdits', ->
it 'calls cancelEdit on each comment view', ->
@view.renderComments()
expect(@view.commentViews.length).toEqual(2)
_.each(@view.commentViews, (commentView) -> spyOn(commentView, 'cancelEdit'))
@view.cancelCommentEdits()
_.each(@view.commentViews, (commentView) -> expect(commentView.cancelEdit).toHaveBeenCalled())

View File

@@ -0,0 +1,72 @@
class @DiscussionSpecHelper
# This is sad. We should avoid dependence on global vars.
@setUpGlobals = ->
DiscussionUtil.loadRoles({"Moderator": [], "Administrator": [], "Community TA": []})
window.$$course_id = "edX/999/test"
window.user = new DiscussionUser({username: "test_user", id: "567", upvoted_ids: []})
DiscussionUtil.setUser(window.user)
@makeTA = () ->
DiscussionUtil.roleIds["Community TA"].push(parseInt(DiscussionUtil.getUser().id))
@makeModerator = () ->
DiscussionUtil.roleIds["Moderator"].push(parseInt(DiscussionUtil.getUser().id))
@makeAjaxSpy = (fakeAjax) ->
spyOn($, "ajax").and.callFake(
(params) ->
fakeAjax(params)
{always: ->}
)
@makeEventSpy = () ->
jasmine.createSpyObj('event', ['preventDefault', 'target'])
@makeCourseSettings = (is_cohorted=true) ->
new DiscussionCourseSettings(
category_map:
children: ['Test Topic', 'Other Topic']
entries:
'Test Topic':
is_cohorted: is_cohorted
id: 'test_topic'
'Other Topic':
is_cohorted: is_cohorted
id: 'other_topic'
is_cohorted: is_cohorted
)
@setUnderscoreFixtures = ->
templateNames = [
'thread', 'thread-show', 'thread-edit',
'thread-response', 'thread-response-show', 'thread-response-edit',
'response-comment-show', 'response-comment-edit',
'thread-list-item', 'discussion-home', 'search-alert',
'new-post', 'thread-type', 'new-post-menu-entry',
'new-post-menu-category', 'topic', 'post-user-display',
'inline-discussion', 'pagination', 'user-profile', 'profile-thread'
]
templateNamesNoTrailingTemplate = [
'forum-action-endorse', 'forum-action-answer', 'forum-action-follow',
'forum-action-vote', 'forum-action-report', 'forum-action-pin',
'forum-action-close', 'forum-action-edit', 'forum-action-delete',
'forum-actions',
]
for templateName in templateNames
templateFixture = readFixtures('common/templates/discussion/' + templateName + '.underscore')
appendSetFixtures($('<script>', { id: templateName + '-template', type: 'text/template' })
.text(templateFixture))
for templateName in templateNamesNoTrailingTemplate
templateFixture = readFixtures('common/templates/discussion/' + templateName + '.underscore')
appendSetFixtures($('<script>', { id: templateName, type: 'text/template' })
.text(templateFixture))
appendSetFixtures("""
<div id="fixture-element"></div>
<div id="discussion-container"
data-course-name="Fake Course"
data-user-create-comment="true"
data-user-create-subcomment="true"
data-read-only="false"
></div>
""")