Move discussion .coffee files to .js
This commit is contained in:
218
common/static/common/js/discussion/content.js
Normal file
218
common/static/common/js/discussion/content.js
Normal 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)
|
||||
131
common/static/common/js/discussion/discussion.js
Normal file
131
common/static/common/js/discussion/discussion.js
Normal 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)
|
||||
173
common/static/common/js/discussion/discussion_module_view.js
Normal file
173
common/static/common/js/discussion/discussion_module_view.js
Normal 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.")
|
||||
)
|
||||
)
|
||||
90
common/static/common/js/discussion/discussion_router.js
Normal file
90
common/static/common/js/discussion/discussion_router.js
Normal 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()
|
||||
)
|
||||
38
common/static/common/js/discussion/main.js
Normal file
38
common/static/common/js/discussion/main.js
Normal 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)
|
||||
@@ -0,0 +1,2 @@
|
||||
if Backbone?
|
||||
class @DiscussionCourseSettings extends Backbone.Model
|
||||
15
common/static/common/js/discussion/models/discussion_user.js
Normal file
15
common/static/common/js/discussion/models/discussion_user.js
Normal 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()
|
||||
346
common/static/common/js/discussion/utils.js
Normal file
346
common/static/common/js/discussion/utils.js
Normal 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(/\<\;highlight\>\;/g, "<span class='search-highlight'>")
|
||||
.replace(/\<\;\/highlight\>\;/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(/^\>\;/gm, ">")
|
||||
converter = Markdown.getMathCompatibleConverter()
|
||||
text = @unescapeHighlightTag @stripLatexHighlight converter.makeHtml text
|
||||
return text.replace(/^>/gm,">")
|
||||
|
||||
@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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
@@ -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(/<mark>/g, "<mark>").replace(/<\/mark>/g, "</mark>"))
|
||||
@@ -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) =>
|
||||
@@ -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);
|
||||
@@ -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.")
|
||||
)
|
||||
125
common/static/common/js/discussion/views/new_post_view.js
Normal file
125
common/static/common/js/discussion/views/new_post_view.js
Normal 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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
227
common/static/common/js/discussion/views/thread_response_view.js
Normal file
227
common/static/common/js/discussion/views/thread_response_view.js
Normal 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()
|
||||
|
||||
115
common/static/common/js/spec/discussion/content_spec.js
Normal file
115
common/static/common/js/spec/discussion/content_spec.js
Normal 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)
|
||||
|
||||
48
common/static/common/js/spec/discussion/utils_spec.js
Normal file
48
common/static/common/js/spec/discussion/utils_spec.js
Normal 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);
|
||||
@@ -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 []
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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')
|
||||
@@ -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.'
|
||||
)
|
||||
@@ -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')
|
||||
@@ -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);
|
||||
@@ -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()
|
||||
@@ -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: ->}
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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>
|
||||
""")
|
||||
Reference in New Issue
Block a user