diff --git a/common/static/coffee/spec/discussion/.gitignore b/common/static/coffee/spec/discussion/.gitignore new file mode 100644 index 0000000000..ac5223af30 --- /dev/null +++ b/common/static/coffee/spec/discussion/.gitignore @@ -0,0 +1,2 @@ +!view/discussion_thread_edit_view_spec.js +!view/discussion_topic_menu_view_spec.js diff --git a/common/static/coffee/spec/discussion/discussion_spec_helper.coffee b/common/static/coffee/spec/discussion/discussion_spec_helper.coffee index c6ea6bfcc1..f7d6835363 100644 --- a/common/static/coffee/spec/discussion/discussion_spec_helper.coffee +++ b/common/static/coffee/spec/discussion/discussion_spec_helper.coffee @@ -71,9 +71,9 @@ browser and pasting the output. When that file changes, this one should be rege + + diff --git a/common/static/coffee/spec/discussion/view/discussion_thread_edit_view_spec.js b/common/static/coffee/spec/discussion/view/discussion_thread_edit_view_spec.js new file mode 100644 index 0000000000..ed5b3cb3dc --- /dev/null +++ b/common/static/coffee/spec/discussion/view/discussion_thread_edit_view_spec.js @@ -0,0 +1,65 @@ +(function() { + 'use strict'; + describe('DiscussionThreadEditView', function() { + beforeEach(function() { + DiscussionSpecHelper.setUpGlobals(); + DiscussionSpecHelper.setUnderscoreFixtures(); + spyOn(DiscussionUtil, 'makeWmdEditor'); + this.threadData = DiscussionViewSpecHelper.makeThreadWithProps(); + this.thread = new Thread(this.threadData); + this.course_settings = new DiscussionCourseSettings({ + 'category_map': { + 'children': ['Topic'], + 'entries': { + 'Topic': { + 'is_cohorted': true, + 'id': 'topic' + } + } + }, + 'is_cohorted': true + }); + + this.createEditView = function (options) { + options = _.extend({ + container: $('#fixture-element'), + model: this.thread, + mode: 'tab', + topicId: 'dummy_id', + course_settings: this.course_settings + }, options); + this.view = new DiscussionThreadEditView(options); + this.view.render(); + }; + }); + + it('can save new data correctly', function() { + var view; + spyOn($, 'ajax').andCallFake(function(params) { + expect(params.url.path()).toEqual(DiscussionUtil.urlFor('update_thread', 'dummy_id')); + expect(params.data.commentable_id).toBe('topic'); + expect(params.data.title).toBe('new_title'); + params.success(); + return {always: function() {}}; + }); + this.createEditView(); + this.view.$el.find('a.topic-title').first().click(); // set new topic + this.view.$('.edit-post-title').val('new_title'); // set new title + this.view.$('.post-update').click(); + expect($.ajax).toHaveBeenCalled(); + + expect(this.thread.get('title')).toBe('new_title'); + expect(this.thread.get('commentable_id')).toBe('topic'); + expect(this.thread.get('courseware_title')).toBe('Topic'); + + expect(this.view.$('.edit-post-title')).toHaveValue(''); + expect(this.view.$('.wmd-preview p')).toHaveText(''); + }); + + it('can close the view', function() { + this.createEditView(); + this.view.$('.post-cancel').click(); + expect($('.edit-post-form')).not.toExist(); + }); + }); +}).call(this); diff --git a/common/static/coffee/spec/discussion/view/discussion_thread_view_spec.coffee b/common/static/coffee/spec/discussion/view/discussion_thread_view_spec.coffee index 01e3bc25c9..06a435e6a9 100644 --- a/common/static/coffee/spec/discussion/view/discussion_thread_view_spec.coffee +++ b/common/static/coffee/spec/discussion/view/discussion_thread_view_spec.coffee @@ -6,6 +6,7 @@ describe "DiscussionThreadView", -> jasmine.Clock.useMock() @threadData = DiscussionViewSpecHelper.makeThreadWithProps({}) @thread = new Thread(@threadData) + @discussion = new Discussion(@thread) spyOn($, "ajax") # Avoid unnecessary boilerplate spyOn(DiscussionThreadShowView.prototype, "convertMath") @@ -44,6 +45,7 @@ describe "DiscussionThreadView", -> checkCommentForm = (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}) renderWithContent(view, {resp_total: 1, children: [{}]}) if mode == "inline" diff --git a/common/static/coffee/spec/discussion/view/discussion_topic_menu_view_spec.js b/common/static/coffee/spec/discussion/view/discussion_topic_menu_view_spec.js new file mode 100644 index 0000000000..2092a52854 --- /dev/null +++ b/common/static/coffee/spec/discussion/view/discussion_topic_menu_view_spec.js @@ -0,0 +1,125 @@ +(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.view.getNameWidth(this.completeText); + }; + + 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('completely show just sub-category', function() { + var dropdownText; + this.createTopicView(); + this.view.maxNameWidth = this.defaultTextWidth - 10; + this.view.$el.find('a.topic-title').first().click(); + dropdownText = this.view.$el.find('.js-selected-topic').text(); + expect(dropdownText.indexOf('…')).toEqual(0); + expect(dropdownText).toContain(this.selectedOptionText); + }); + + it('partially show sub-category', function() { + this.createTopicView(); + var parentWidth = this.view.getNameWidth(this.parentCategoryText), + dropdownText; + this.view.maxNameWidth = this.defaultTextWidth - parentWidth; + this.view.$el.find('a.topic-title').first().click(); + dropdownText = this.view.$el.find('.js-selected-topic').text(); + expect(dropdownText.indexOf('…')).toEqual(0); + expect(dropdownText.lastIndexOf('…')).toBeGreaterThan(0); + }); + + it('broken span doesn\'t occur', function() { + var dropdownText; + this.createTopicView(); + this.view.maxNameWidth = this.view.getNameWidth(this.selectedOptionText) + 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); diff --git a/common/static/coffee/spec/discussion/view/new_post_view_spec.coffee b/common/static/coffee/spec/discussion/view/new_post_view_spec.coffee index cb0ee2cc61..0dc99389ba 100644 --- a/common/static/coffee/spec/discussion/view/new_post_view_spec.coffee +++ b/common/static/coffee/spec/discussion/view/new_post_view_spec.coffee @@ -10,115 +10,6 @@ describe "NewPostView", -> ) @discussion = new Discussion([], {pages: 1}) - describe "Drop down works correct", -> - beforeEach -> - @course_settings = new DiscussionCourseSettings({ - "category_map": { - "subcategories": { - "Basic Question Types": { - "subcategories": {}, - "children": ["Selection From Options"], - "entries": { - "Selection From Options": { - "sort_key": null, - "is_cohorted": true, - "id": "cba3e4cd91d0466b9ac50926e495b76f" - } - }, - }, - }, - "children": ["Basic Question Types"], - "entries": {} - }, - "allow_anonymous": true, - "allow_anonymous_to_peers": true, - "is_cohorted": true - }) - @view = new NewPostView( - el: $("#fixture-element"), - collection: @discussion, - course_settings: @course_settings, - mode: "tab" - ) - @view.render() - @parent_category_text = "Basic Question Types" - @selected_option_text = "Selection From Options" - - it "completely show parent category and sub-category", -> - complete_text = @parent_category_text + " / " + @selected_option_text - selected_text_width = @view.getNameWidth(complete_text) - @view.maxNameWidth = selected_text_width + 1 - @view.$el.find( "a.topic-title" ).first().click() - dropdown_text = @view.$el.find(".js-selected-topic").text() - expect(complete_text).toEqual(dropdown_text) - - it "completely show just sub-category", -> - complete_text = @parent_category_text + " / " + @selected_option_text - selected_text_width = @view.getNameWidth(complete_text) - @view.maxNameWidth = selected_text_width - 10 - @view.$el.find( "a.topic-title" ).first().click() - dropdown_text = @view.$el.find(".js-selected-topic").text() - expect(dropdown_text.indexOf("…")).toEqual(0) - expect(dropdown_text).toContain(@selected_option_text) - - it "partially show sub-category", -> - parent_width = @view.getNameWidth(@parent_category_text) - complete_text = @parent_category_text + " / " + @selected_option_text - selected_text_width = @view.getNameWidth(complete_text) - @view.maxNameWidth = selected_text_width - parent_width - @view.$el.find( "a.topic-title" ).first().click() - dropdown_text = @view.$el.find(".js-selected-topic").text() - expect(dropdown_text.indexOf("…")).toEqual(0) - expect(dropdown_text.lastIndexOf("…")).toBeGreaterThan(0) - - it "broken span doesn't occur", -> - complete_text = @parent_category_text + " / " + @selected_option_text - selected_text_width = @view.getNameWidth(complete_text) - @view.maxNameWidth = @view.getNameWidth(@selected_option_text) + 100 - @view.$el.find( "a.topic-title" ).first().click() - dropdown_text = @view.$el.find(".js-selected-topic").text() - expect(dropdown_text.indexOf("/ span>")).toEqual(-1) - - describe "cohort selector", -> - renderWithCohortedTopics = (course_settings, view, isCohortedFirst) -> - course_settings.set( - "category_map", - { - "children": if isCohortedFirst then ["Cohorted", "Non-Cohorted"] else ["Non-Cohorted", "Cohorted"], - "entries": { - "Non-Cohorted": { - "sort_key": null, - "is_cohorted": false, - "id": "non-cohorted" - }, - "Cohorted": { - "sort_key": null, - "is_cohorted": true, - "id": "cohorted" - } - } - } - ) - DiscussionSpecHelper.makeModerator() - view.render() - - expectCohortSelectorEnabled = (view, enabled) -> - expect(view.$(".js-group-select").prop("disabled")).toEqual(not enabled) - if not enabled - expect(view.$(".js-group-select option:selected").attr("value")).toEqual("") - - it "is disabled with non-cohorted default topic and enabled by selecting cohorted topic", -> - renderWithCohortedTopics(@course_settings, @view, false) - expectCohortSelectorEnabled(@view, false) - @view.$("a.topic-title[data-discussion-id=cohorted]").click() - expectCohortSelectorEnabled(@view, true) - - it "is enabled with cohorted default topic and disabled by selecting non-cohorted topic", -> - renderWithCohortedTopics(@course_settings, @view, true) - expectCohortSelectorEnabled(@view, true) - @view.$("a.topic-title[data-discussion-id=non-cohorted]").click() - expectCohortSelectorEnabled(@view, false) - describe "cohort selector", -> beforeEach -> @course_settings = new DiscussionCourseSettings({ diff --git a/common/static/coffee/src/discussion/.gitignore b/common/static/coffee/src/discussion/.gitignore new file mode 100644 index 0000000000..3c396b9365 --- /dev/null +++ b/common/static/coffee/src/discussion/.gitignore @@ -0,0 +1,2 @@ +!views/discussion_thread_edit_view.js +!views/discussion_topic_menu_view.js diff --git a/common/static/coffee/src/discussion/discussion_module_view.coffee b/common/static/coffee/src/discussion/discussion_module_view.coffee index 9dc15f7e47..ed5c3c914e 100644 --- a/common/static/coffee/src/discussion/discussion_module_view.coffee +++ b/common/static/coffee/src/discussion/discussion_module_view.coffee @@ -99,7 +99,13 @@ if Backbone? @newPostForm = $('.new-post-article') @threadviews = @discussion.map (thread) -> - new DiscussionThreadView el: @$("article#thread_#{thread.id}"), model: thread, mode: "inline" + new DiscussionThreadView( + el: @$("article#thread_#{thread.id}"), + model: thread, + mode: "inline", + course_settings: @course_settings, + topicId: discussionId + ) _.each @threadviews, (dtv) -> dtv.render() DiscussionUtil.bulkUpdateContentInfo(window.$$annotated_content_info) @newPostView = new NewPostView( @@ -123,7 +129,14 @@ if Backbone? # TODO: When doing pagination, this will need to repaginate. Perhaps just reload page 1? article = $("
") @$('section.discussion > .threads').prepend(article) - threadView = new DiscussionThreadView el: article, model: thread, mode: "inline" + + threadView = new DiscussionThreadView( + el: article, + model: thread, + mode: "inline", + course_settings: @course_settings, + topicId: @$el.data("discussion-id") + ) threadView.render() @threadviews.unshift threadView diff --git a/common/static/coffee/src/discussion/discussion_router.coffee b/common/static/coffee/src/discussion/discussion_router.coffee index 27c227f386..ba76d5e409 100644 --- a/common/static/coffee/src/discussion/discussion_router.coffee +++ b/common/static/coffee/src/discussion/discussion_router.coffee @@ -54,7 +54,12 @@ if Backbone? if(@newPost.is(":visible")) @newPost.fadeOut() - @main = new DiscussionThreadView(el: $(".forum-content"), model: @thread, mode: "tab") + @main = new DiscussionThreadView( + el: $(".forum-content"), + model: @thread, + mode: "tab", + course_settings: @course_settings, + ) @main.render() @main.on "thread:responses:rendered", => @nav.updateSidebar() diff --git a/common/static/coffee/src/discussion/views/discussion_thread_edit_view.coffee b/common/static/coffee/src/discussion/views/discussion_thread_edit_view.coffee deleted file mode 100644 index 918c70f576..0000000000 --- a/common/static/coffee/src/discussion/views/discussion_thread_edit_view.coffee +++ /dev/null @@ -1,25 +0,0 @@ -if Backbone? - class @DiscussionThreadEditView extends Backbone.View - - events: - "click .post-update": "update" - "click .post-cancel": "cancel_edit" - - $: (selector) -> - @$el.find(selector) - - initialize: -> - super() - - render: -> - @template = _.template($("#thread-edit-template").html()) - @$el.html(@template(@model.toJSON())) - @delegateEvents() - DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), "edit-post-body" - @ - - update: (event) -> - @trigger "thread:update", event - - cancel_edit: (event) -> - @trigger "thread:cancel_edit", event diff --git a/common/static/coffee/src/discussion/views/discussion_thread_edit_view.js b/common/static/coffee/src/discussion/views/discussion_thread_edit_view.js new file mode 100644 index 0000000000..26d49c11bc --- /dev/null +++ b/common/static/coffee/src/discussion/views/discussion_thread_edit_view.js @@ -0,0 +1,103 @@ +(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.topicId = options.topicId; + _.bindAll(this); + return this; + }, + + render: function() { + this.template = _.template($('#thread-edit-template').html()); + this.$el.html(this.template(this.model.toJSON())).appendTo(this.container); + this.submitBtn = this.$('.post-update'); + if (this.isTabMode()) { + 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(), + body = this.$('.edit-post-body textarea').val(), + commentableId = this.isTabMode() ? this.topicView.getCurrentTopicId() : null; + + return DiscussionUtil.safeAjax({ + $elem: this.submitBtn, + $loading: this.submitBtn, + url: DiscussionUtil.urlFor('update_thread', this.model.id), + type: 'POST', + dataType: 'json', + async: false, // @TODO when the rest of the stuff below is made to work properly.. + data: { + title: title, + body: body, + commentable_id: commentableId + }, + error: DiscussionUtil.formErrorHandler(this.$('.post-errors')), + success: function() { + var newAttrs = { + title: title, + body: body + }; + // @TODO: Move this out of the callback, this makes it feel sluggish + this.$('.edit-post-title').val('').attr('prev-text', ''); + this.$('.edit-post-body textarea').val('').attr('prev-text', ''); + this.$('.wmd-preview p').html(''); + if (this.isTabMode()) { + _.extend(newAttrs, { + commentable_id: commentableId, + courseware_title: this.topicView.getFullTopicName() + }); + } + this.model.set(newAttrs).unset('abbreviatedBody'); + this.trigger('thread:updated'); + }.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); diff --git a/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee b/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee index 9fb33c7952..e8e4b50073 100644 --- a/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee +++ b/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee @@ -5,7 +5,7 @@ if Backbone? "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": "selectTopic" + "click .forum-nav-browse-title": "selectTopicHandler" "keydown .forum-nav-search-input": "performSearch" "change .forum-nav-sort-control": "sortThreads" "click .forum-nav-thread-link": "threadSelected" @@ -130,12 +130,12 @@ if Backbone? ) @$(".forum-nav-sort-control").val(@collection.sort_preference) - $(window).bind "load", @updateSidebar - $(window).bind "scroll", @updateSidebar - $(window).bind "resize", @updateSidebar + $(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() @ @@ -185,7 +185,7 @@ if Backbone? when 'search' options.search_text = @current_search if @group_id - options.group_id = @group_id + options.group_id = @group_id when 'followed' options.user_id = window.user.id options.group_id = "all" @@ -196,8 +196,7 @@ if Backbone? 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 @@ -262,7 +261,7 @@ if Backbone? else $('input.email-setting').removeAttr('checked') thread_id = null - @trigger("thread:removed") + @trigger("thread:removed") #select all threads isBrowseMenuVisible: => @@ -359,12 +358,15 @@ if Backbone? name = prefix + rawName + gettext("…") return name - selectTopic: (event) -> + selectTopicHandler: (event) -> event.preventDefault() + @selectTopic $(event.target) + + selectTopic: ($target) -> @hideBrowseMenu() @clearSearch() - item = $(event.target).closest('.forum-nav-browse-menu-item') + item = $target.closest('.forum-nav-browse-menu-item') @setCurrentTopicDisplay(@getPathText(item)) if item.hasClass("forum-nav-browse-menu-all") @discussionIds = "" @@ -388,7 +390,7 @@ if Backbone? 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 @@ -403,7 +405,7 @@ if Backbone? if callback? callback() - + retrieveDiscussions: (discussion_ids) -> @discussionIds = discussion_ids.join(',') @mode = 'commentables' diff --git a/common/static/coffee/src/discussion/views/discussion_thread_view.coffee b/common/static/coffee/src/discussion/views/discussion_thread_view.coffee index 82652b6370..09ac128026 100644 --- a/common/static/coffee/src/discussion/views/discussion_thread_view.coffee +++ b/common/static/coffee/src/discussion/views/discussion_thread_view.coffee @@ -21,6 +21,13 @@ if Backbone? @mode = options.mode or "inline" # allowed values are "tab" or "inline" if @mode not in ["tab", "inline"] throw new Error("invalid mode: " + @mode) + + # 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 @@ -254,49 +261,20 @@ if Backbone? @createEditView() @renderEditView() - update: (event) => - - newTitle = @editView.$(".edit-post-title").val() - newBody = @editView.$(".edit-post-body textarea").val() - - url = DiscussionUtil.urlFor('update_thread', @model.id) - - DiscussionUtil.safeAjax - $elem: $(event.target) - $loading: $(event.target) if event - url: url - type: "POST" - dataType: 'json' - async: false # TODO when the rest of the stuff below is made to work properly.. - data: - title: newTitle - body: newBody - - error: DiscussionUtil.formErrorHandler(@$(".edit-post-form-errors")) - success: (response, textStatus) => - # TODO: Move this out of the callback, this makes it feel sluggish - @editView.$(".edit-post-title").val("").attr("prev-text", "") - @editView.$(".edit-post-body textarea").val("").attr("prev-text", "") - @editView.$(".wmd-preview p").html("") - - @model.set - title: newTitle - body: newBody - @model.unset("abbreviatedBody") - - @createShowView() - @renderShowView() - createEditView: () -> - if @showView? @showView.undelegateEvents() @showView.$el.empty() @showView = null - @editView = new DiscussionThreadEditView(model: @model) - @editView.bind "thread:update", @update - @editView.bind "thread:cancel_edit", @cancelEdit + @editView = new DiscussionThreadEditView( + container: @$('.thread-content-wrapper') + model: @model + mode: @mode + course_settings: @options.course_settings + topicId: @model.get('commentable_id') + ) + @editView.bind "thread:updated thread:cancel_edit", @closeEditView renderSubView: (view) -> view.setElement(@$('.thread-content-wrapper')) @@ -304,15 +282,9 @@ if Backbone? view.delegateEvents() renderEditView: () -> - @renderSubView(@editView) + @editView.render() createShowView: () -> - - if @editView? - @editView.undelegateEvents() - @editView.$el.empty() - @editView = null - @showView = new DiscussionThreadShowView({model: @model, mode: @mode}) @showView.bind "thread:_delete", @_delete @showView.bind "thread:edit", @edit @@ -320,8 +292,7 @@ if Backbone? renderShowView: () -> @renderSubView(@showView) - cancelEdit: (event) => - event.preventDefault() + closeEditView: (event) => @createShowView() @renderShowView() diff --git a/common/static/coffee/src/discussion/views/discussion_topic_menu_view.js b/common/static/coffee/src/discussion/views/discussion_topic_menu_view.js new file mode 100644 index 0000000000..57e44e626a --- /dev/null +++ b/common/static/coffee/src/discussion/views/discussion_topic_menu_view.js @@ -0,0 +1,193 @@ +(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': this.DiscussionFilter.filterDrop + }, + + attributes: { + 'class': 'post-field' + }, + + initialize: function(options) { + this.course_settings = options.course_settings; + this.currentTopicId = options.topicId; + this.maxNameWidth = 100; + _.bindAll(this); + 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); + // Set here because 1) the window might get resized and things could + // change and 2) can't set in initialize because the button is hidden + this.maxNameWidth = this.dropdownButton.width() - 40; + 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 + getNameWidth: function(name) { + var test = $('
'), + width; + + test.css({ + 'font-size': this.dropdownButton.css('font-size'), + 'opacity': 0, + 'position': 'absolute', + 'left': -1000, + 'top': -1000 + }).html(name).appendTo(document.body); + width = test.width(); + test.remove(); + return width; + }, + + // @TODO move into utils.coffee + fitName: function(name) { + var ellipsisText = gettext('…'), + partialName, path, rawName; + + if (this.getNameWidth(name) < 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 (this.getNameWidth(partialName) < this.maxNameWidth) { + return partialName; + } + } + rawName = path[0]; + name = ellipsisText + ' / ' + rawName; + while (this.getNameWidth(name) > this.maxNameWidth) { + rawName = rawName.slice(0, -1); + name = ellipsisText + ' / ' + rawName + ' ' + ellipsisText; + } + } + return name; + } + }); + } +}).call(this, Backbone); diff --git a/common/static/coffee/src/discussion/views/new_post_view.coffee b/common/static/coffee/src/discussion/views/new_post_view.coffee index 04b6ad65fe..3419d00aaf 100644 --- a/common/static/coffee/src/discussion/views/new_post_view.coffee +++ b/common/static/coffee/src/discussion/views/new_post_view.coffee @@ -6,7 +6,6 @@ if Backbone? if @mode not in ["tab", "inline"] throw new Error("invalid mode: " + @mode) @course_settings = options.course_settings - @maxNameWidth = 100 @topicId = options.topicId render: () -> @@ -16,29 +15,21 @@ if Backbone? mode: @mode, form_id: @mode + (if @topicId then "-" + @topicId else "") }) - context.topics_html = @renderCategoryMap(@course_settings.get("category_map")) if @mode is "tab" @$el.html(_.template($("#new-post-template").html(), context)) - - if @mode is "tab" - # set up the topic dropdown in tab mode - @dropdownButton = @$(".post-topic-button") - @topicMenu = @$(".topic-menu-wrapper") - @hideTopicDropdown() - @setTopic(@$("a.topic-title").first()) - + 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" - renderCategoryMap: (map) -> - category_template = _.template($("#new-post-menu-category-template").html()) - entry_template = _.template($("#new-post-menu-entry-template").html()) - html = "" - for name in map.children - if name of map.entries - 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: @renderCategoryMap(map.subcategories[name])}) - html + addField: (fieldView) -> + @$('.forum-new-post-form-wrapper').append fieldView + + isTabMode: () -> + @mode is "tab" getCohortOptions: () -> if @course_settings.get("is_cohorted") and DiscussionUtil.isPrivilegedUser() @@ -50,19 +41,15 @@ if Backbone? events: "submit .forum-new-post-form": "createPost" - "click .post-topic-button": "toggleTopicDropdown" - "click .topic-menu-wrapper": "handleTopicEvent" - "click .topic-filter-label": "ignoreClick" - "keyup .topic-filter-input": DiscussionFilter.filterDrop "change .post-option-input": "postOptionChange" "click .cancel": "cancel" "reset .forum-new-post-form": "updateStyles" - # Because we want the behavior that when the body is clicked the menu is - # closed, we need to ignore clicks in the search field and stop propagation. - # Without this, clicking the search field would also close the menu. - ignoreClick: (event) -> - event.stopPropagation() + toggleGroupDropdown: ($target) -> + if $target.data('cohorted') + $('.js-group-select').prop('disabled', false); + else + $('.js-group-select').val('').prop('disabled', true); postOptionChange: (event) -> $target = $(event.target) @@ -77,13 +64,14 @@ if Backbone? 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") + 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") - url = DiscussionUtil.urlFor('create_thread', @topicId) + topicId = if @isTabMode() then @topicView.getCurrentTopicId() else @topicId + url = DiscussionUtil.urlFor('create_thread', topicId) DiscussionUtil.safeAjax $elem: $(event.target) @@ -108,97 +96,6 @@ if Backbone? @resetForm() @collection.add thread - - toggleTopicDropdown: (event) -> - event.preventDefault() - event.stopPropagation() - if @menuOpen - @hideTopicDropdown() - else - @showTopicDropdown() - - showTopicDropdown: () -> - @menuOpen = true - @dropdownButton.addClass('dropped') - @topicMenu.show() - $(".form-topic-drop-search-input").focus() - - $("body").bind "click", @hideTopicDropdown - - # Set here because 1) the window might get resized and things could - # change and 2) can't set in initialize because the button is hidden - @maxNameWidth = @dropdownButton.width() - 40 - - # Need a fat arrow because hideTopicDropdown is passed as a callback to bind - hideTopicDropdown: () => - @menuOpen = false - @dropdownButton.removeClass('dropped') - @topicMenu.hide() - - $("body").unbind "click", @hideTopicDropdown - - handleTopicEvent: (event) -> - event.preventDefault() - event.stopPropagation() - @setTopic($(event.target)) - - setTopic: ($target) -> - if $target.data('discussion-id') - @topicText = $target.html() - @topicText = @getFullTopicName($target) - @topicId = $target.data('discussion-id') - @setSelectedTopic() - if $target.data("cohorted") - $(".js-group-select").prop("disabled", false) - else - $(".js-group-select").val("") - $(".js-group-select").prop("disabled", true) - @hideTopicDropdown() - - setSelectedTopic: -> - @$(".js-selected-topic").html(@fitName(@topicText)) - - getFullTopicName: (topicElement) -> - name = topicElement.html() - topicElement.parents('.topic-submenu').each -> - name = $(this).siblings('.topic-title').text() + ' / ' + name - return name - - getNameWidth: (name) -> - test = $("
") - test.css - "font-size": @dropdownButton.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) -> - width = @getNameWidth(name) - if width < @maxNameWidth - return name - path = (x.replace /^\s+|\s+$/g, "" for x in name.split("/")) - while path.length > 1 - path.shift() - partialName = gettext("…") + " / " + path.join(" / ") - if @getNameWidth(partialName) < @maxNameWidth - return partialName - - rawName = path[0] - - name = gettext("…") + " / " + rawName - - while @getNameWidth(name) > @maxNameWidth - rawName = rawName[0...rawName.length-1] - name = gettext("…") + " / " + rawName + " " + gettext("…") - - return name - cancel: (event) -> event.preventDefault() if not confirm gettext("Your post will be discarded.") @@ -210,8 +107,8 @@ if Backbone? @$(".forum-new-post-form")[0].reset() DiscussionUtil.clearFormErrors(@$(".post-errors")) @$(".wmd-preview p").html("") - if @mode is "tab" - @setTopic(@$("a.topic-title").first()) + 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 diff --git a/lms/djangoapps/django_comment_client/base/tests.py b/lms/djangoapps/django_comment_client/base/tests.py index 51a38c4a9f..982abde32e 100644 --- a/lms/djangoapps/django_comment_client/base/tests.py +++ b/lms/djangoapps/django_comment_client/base/tests.py @@ -15,7 +15,7 @@ from django_comment_client.base import views from django_comment_client.tests.group_id import CohortedTopicGroupIdTestMixin, NonCohortedTopicGroupIdTestMixin, GroupIdAssertionMixin from django_comment_client.tests.utils import CohortedContentTestCase from django_comment_client.tests.unicode import UnicodeTestMixin -from django_comment_common.models import Role, FORUM_ROLE_STUDENT +from django_comment_common.models import Role from django_comment_common.utils import seed_permissions_roles from student.tests.factories import CourseEnrollmentFactory, UserFactory from util.testing import UrlResetMixin @@ -160,7 +160,6 @@ class ThreadActionGroupIdTestCase( ) - @override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) @patch('lms.lib.comment_client.utils.requests.request') class ViewsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSetupMixin): @@ -369,6 +368,15 @@ class ViewsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSetupMixin): mock_request ) + @patch('django_comment_client.base.views.get_discussion_id_map', return_value={"test_commentable": {}}) + def test_update_thread_wrong_commentable_id(self, mock_get_discussion_id_map, mock_request): + self._test_request_error( + "update_thread", + {"thread_id": "dummy", "course_id": self.course_id.to_deprecated_string()}, + {"body": "foo", "title": "foo", "commentable_id": "wrong_commentable"}, + mock_request + ) + def test_create_comment_no_body(self, mock_request): self._test_request_error( "create_comment", @@ -460,7 +468,7 @@ class ViewsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSetupMixin): "at_position_list": [], "closed": is_closed, "id": "518d4237b023791dca00000d", - "user_id": "1","username": "robot", + "user_id": "1", "username": "robot", "votes": { "count": 0, "up_count": 0, @@ -853,13 +861,14 @@ class UpdateThreadUnicodeTestCase(ModuleStoreTestCase, UnicodeTestMixin, MockReq self.student = UserFactory.create() CourseEnrollmentFactory(user=self.student, course_id=self.course.id) + @patch('django_comment_client.base.views.get_discussion_id_map', return_value={"test_commentable": {}}) @patch('lms.lib.comment_client.utils.requests.request') - def _test_unicode_data(self, text, mock_request): + def _test_unicode_data(self, text, mock_request, mock_get_discussion_id_map): self._set_mock_request_data(mock_request, { "user_id": str(self.student.id), "closed": False, }) - request = RequestFactory().post("dummy_url", {"body": text, "title": text}) + request = RequestFactory().post("dummy_url", {"body": text, "title": text, "commentable_id": "test_commentable"}) request.user = self.student request.view_name = "update_thread" response = views.update_thread(request, course_id=self.course.id.to_deprecated_string(), thread_id="dummy_thread_id") @@ -868,6 +877,7 @@ class UpdateThreadUnicodeTestCase(ModuleStoreTestCase, UnicodeTestMixin, MockReq self.assertTrue(mock_request.called) self.assertEqual(mock_request.call_args[1]["data"]["body"], text) self.assertEqual(mock_request.call_args[1]["data"]["title"], text) + self.assertEqual(mock_request.call_args[1]["data"]["commentable_id"], "test_commentable") @override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) diff --git a/lms/djangoapps/django_comment_client/base/views.py b/lms/djangoapps/django_comment_client/base/views.py index 50fe1a24eb..4b8d940543 100644 --- a/lms/djangoapps/django_comment_client/base/views.py +++ b/lms/djangoapps/django_comment_client/base/views.py @@ -18,8 +18,6 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey from courseware.access import has_access from courseware.courses import get_course_with_access, get_course_by_id -from course_groups.models import CourseUserGroup -from course_groups.cohorts import get_cohort_by_id, get_cohort_id, is_commentable_cohorted import django_comment_client.settings as cc_settings from django_comment_client.utils import ( add_courseware_context, @@ -28,7 +26,8 @@ from django_comment_client.utils import ( JsonError, JsonResponse, prepare_content, - get_group_id_for_comments_service + get_group_id_for_comments_service, + get_discussion_id_map, ) from django_comment_client.permissions import check_permissions_by_view, cached_has_permission import lms.lib.comment_client as cc @@ -139,12 +138,21 @@ def update_thread(request, course_id, thread_id): return JsonError(_("Title can't be empty")) if 'body' not in request.POST or not request.POST['body'].strip(): return JsonError(_("Body can't be empty")) + course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) thread = cc.Thread.find(thread_id) thread.body = request.POST["body"] thread.title = request.POST["title"] - thread.save() + if "commentable_id" in request.POST: + course = get_course_with_access(request.user, 'load', course_key) + id_map = get_discussion_id_map(course) + if request.POST.get("commentable_id") in id_map: + thread.commentable_id = request.POST["commentable_id"] + else: + return JsonError(_("Topic doesn't exist")) + + thread.save() if request.is_ajax(): return ajax_content_response(request, course_key, thread.to_dict()) else: @@ -614,6 +622,7 @@ def upload(request, course_id): # ajax upload file to a question or answer } }) + @require_GET @login_required def users(request, course_id): @@ -640,7 +649,7 @@ def users(request, course_id): try: matched_user = User.objects.get(username=username) cc_user = cc.User.from_django_user(matched_user) - cc_user.course_id=course_key + cc_user.course_id = course_key cc_user.retrieve(complete=False) if (cc_user['threads_count'] + cc_user['comments_count']) > 0: user_objs.append({ diff --git a/lms/djangoapps/django_comment_client/utils.py b/lms/djangoapps/django_comment_client/utils.py index 9894f17e90..4500dfb875 100644 --- a/lms/djangoapps/django_comment_client/utils.py +++ b/lms/djangoapps/django_comment_client/utils.py @@ -71,7 +71,7 @@ def _get_discussion_modules(course): return filter(has_required_keys, all_modules) -def _get_discussion_id_map(course): +def get_discussion_id_map(course): def get_entry(module): discussion_id = module.discussion_id title = module.discussion_target @@ -352,7 +352,7 @@ def extend_content(content): def add_courseware_context(content_list, course): - id_map = _get_discussion_id_map(course) + id_map = get_discussion_id_map(course) for content in content_list: commentable_id = content['commentable_id'] diff --git a/lms/static/sass/application-extend2.scss.mako b/lms/static/sass/application-extend2.scss.mako index e4506954d5..d3094326d0 100644 --- a/lms/static/sass/application-extend2.scss.mako +++ b/lms/static/sass/application-extend2.scss.mako @@ -56,7 +56,7 @@ @import "discussion/elements/labels"; @import "discussion/elements/navigation"; @import "discussion/views/thread"; -@import "discussion/views/new-post"; +@import "discussion/views/create-edit-post"; @import "discussion/views/response"; @import 'discussion/utilities/developer'; @import 'discussion/utilities/shame'; diff --git a/lms/static/sass/discussion/utilities/_shame.scss b/lms/static/sass/discussion/utilities/_shame.scss index af92ff2e5a..928c9b3cdf 100644 --- a/lms/static/sass/discussion/utilities/_shame.scss +++ b/lms/static/sass/discussion/utilities/_shame.scss @@ -107,7 +107,8 @@ li[class*=forum-nav-thread-label-] { // new post form // ------------- -.forum-new-post-form { +.forum-new-post-form, +.edit-post-form { // Override global label rules .post-type { text-shadow: none; @@ -127,7 +128,7 @@ li[class*=forum-nav-thread-label-] { margin-bottom: 0; } - // Override global span rules + // Override global span rules .post-topic-button .drop-arrow { line-height: 36px; } diff --git a/lms/static/sass/discussion/views/_new-post.scss b/lms/static/sass/discussion/views/_create-edit-post.scss similarity index 96% rename from lms/static/sass/discussion/views/_new-post.scss rename to lms/static/sass/discussion/views/_create-edit-post.scss index db80cd986b..d02cf6002e 100644 --- a/lms/static/sass/discussion/views/_new-post.scss +++ b/lms/static/sass/discussion/views/_create-edit-post.scss @@ -2,7 +2,8 @@ // ==================== // UI: form structure -.forum-new-post-form { +.forum-new-post-form, +.edit-post-form { @include clearfix; box-sizing: border-box; margin: 0; @@ -64,7 +65,8 @@ // ==================== // UI: inputs -.forum-new-post-form { +.forum-new-post-form, +.edit-post-form { .post-topic-button { @include white-button; @extend %cont-truncated; @@ -172,7 +174,8 @@ // ==================== // UI: errors - new post creation -.forum-new-post-form { +.forum-new-post-form, +.edit-post-form { .post-errors { margin-bottom: $baseline; border-radius: 3px; @@ -199,7 +202,8 @@ // UI: topic menu // TO-DO: refactor to use _navigation.scss as general topic selector -.forum-new-post-form .post-topic { +.forum-new-post-form .post-topic , +.edit-post-form .post-topic { position: relative; .topic-menu-wrapper { diff --git a/lms/templates/discussion/_underscore_templates.html b/lms/templates/discussion/_underscore_templates.html index cb5d7433f7..cf9ca04d65 100644 --- a/lms/templates/discussion/_underscore_templates.html +++ b/lms/templates/discussion/_underscore_templates.html @@ -54,9 +54,9 @@ % endfor + + <%def name="primaryAction(action_class, icon, sr_label, unchecked_label, checked_label)">