Commit 70067eb6 by E. Kolpakov

Moved discussion XBlock to edx-platform

(cherry picked from commit a7c0a2a)
parent 6efca845
......@@ -19,10 +19,6 @@ from contentstore.utils import reverse_course_url, reverse_usage_url
__all__ = ['edge', 'event', 'landing']
# Add Discussion templates
add_lookup('lms.main', 'templates', package='discussion_app')
# points to the temporary course landing page with log in and sign up
def landing(request, org, course, coursename):
return render_to_response('temp-course-landing.html', {})
......
......@@ -17,6 +17,3 @@ def run():
clear_lookups(namespace)
for directory in directories:
add_lookup(namespace, directory)
# Add Discussion templates
add_lookup('main', 'templates', package='discussion_app')
from .discussion_forum import DiscussionXBlock, DiscussionCourseXBlock
import logging
from xblock.core import XBlock
from xblock.fields import Scope, String
from xblock.fragment import Fragment
from .utils import (
render_template,
render_mako_template,
render_mustache_templates,
get_js_urls, get_css_urls,
asset_to_static_url
)
log = logging.getLogger(__name__)
@XBlock.needs('discussion')
class DiscussionXBlock(XBlock):
""" Provides functionality similar to discussion XModule in inline mode """
display_name = String(
display_name="Display Name",
help="Display name for this module",
default="Discussion",
scope=Scope.settings
)
data = String(
help="XML data for the problem",
scope=Scope.content,
default="<discussion></discussion>"
)
discussion_category = String(
display_name="Category",
default="Week 1",
help="A category name for the discussion. This name appears in the left pane of the discussion forum for the course.",
scope=Scope.settings
)
discussion_target = String(
display_name="Subcategory",
default="Topic-Level Student-Visible Label",
help="A subcategory name for the discussion. This name appears in the left pane of the discussion forum for the course.",
scope=Scope.settings
)
sort_key = String(scope=Scope.settings)
@property
def discussion_id(self):
"""
:return: int discussion id
"""
return self.scope_ids.usage_id.block_id
@property
def course_id(self):
"""
:return: int course id
"""
# TODO really implement this
# pylint: disable=no-member
if hasattr(self, 'xmodule_runtime'):
if hasattr(self.xmodule_runtime.course_id, 'to_deprecated_string'):
return self.xmodule_runtime.course_id.to_deprecated_string()
else:
return self.xmodule_runtime.course_id
return 'foo'
def student_view(self, context=None): # pylint: disable=unused-argument
""" Renders student view for LMS and Studio """
# pylint: disable=no-member
if hasattr(self, 'xmodule_runtime') and getattr(self.xmodule_runtime, 'is_author_mode', False):
fragment = self._student_view_studio()
else:
fragment = self._student_view_lms()
return fragment
def _student_view_lms(self):
""" Renders student view for LMS """
fragment = Fragment()
discussion_service = self.xmodule_runtime.service(self, 'discussion') # pylint: disable=no-member
context = discussion_service.get_inline_template_context(self.discussion_id)
context['discussion_id'] = self.discussion_id
fragment.add_content(render_mako_template('discussion/_discussion_inline.html', context))
for url in get_js_urls():
fragment.add_javascript_url(url)
for url in get_css_urls():
fragment.add_css_url(url)
fragment.add_javascript(render_template('static/js/discussion_inline.js', {'course_id': self.course_id}))
fragment.add_content(render_mustache_templates())
fragment.initialize_js('DiscussionInlineBlock')
return fragment
def _student_view_studio(self):
""" Renders student view for Studio """
fragment = Fragment()
fragment.add_content(render_mako_template(
'discussion/_discussion_inline_studio.html',
{'discussion_id': self.discussion_id}
))
fragment.add_css_url(asset_to_static_url('xblock/discussion/css/discussion-studio.css'))
return fragment
@XBlock.json_handler
def studio_submit(self, data, suffix=''): # pylint: disable=unused-argument
""" Handles Studio submit event """
log.info("submitted: {}".format(data))
self.display_name = data.get("display_name", "Untitled Discussion Topic")
self.discussion_category = data.get("discussion_category", None)
self.discussion_target = data.get("discussion_target", None)
return {
"display_name": self.display_name,
"discussion_category": self.discussion_category,
"discussion_target": self.discussion_target
}
def studio_view(self, context=None): # pylint: disable=unused-argument
""" Renders author view for Studio """
fragment = Fragment()
context = {
"display_name": self.display_name,
"discussion_category": self.discussion_category,
"discussion_target": self.discussion_target
}
log.info("rendering template in context: {}".format(context))
fragment.add_content(render_mako_template('discussion/discussion_inline_edit.html', context))
fragment.add_javascript_url(asset_to_static_url('xblock/discussion/js/discussion_inline_edit.js'))
fragment.initialize_js('DiscussionEditBlock')
return fragment
@staticmethod
def workbench_scenarios():
"""A canned scenario for display in the workbench."""
return [
("Discussion XBlock",
"""<vertical_demo>
<discussion-forum/>
</vertical_demo>
"""),
]
@XBlock.needs('discussion')
class DiscussionCourseXBlock(XBlock):
""" Provides functionality similar to discussion XModule in tab mode """
display_name = String(
display_name="Display Name",
help="Display name for this module",
default="Discussion Course",
scope=Scope.settings
)
def student_view(self, context=None): # pylint: disable=unused-argument
""" Renders student view for LMS and Studio """
# pylint: disable=no-member
if hasattr(self, 'xmodule_runtime') and getattr(self.xmodule_runtime, 'is_author_mode', False):
fragment = self._student_view_studio()
else:
fragment = self._student_view_lms()
return fragment
def _student_view_lms(self):
""" Renders student view for LMS """
fragment = Fragment()
fragment.add_css_url(asset_to_static_url('xblock/discussion/css/discussion-course-custom.css'))
discussion_service = self.xmodule_runtime.service(self, 'discussion') # pylint: disable=no-member
context = discussion_service.get_course_template_context()
context['enable_new_post_btn'] = True
fragment.add_content(render_mako_template('discussion/_discussion_course.html', context))
fragment.add_javascript(render_template('static/js/discussion_course.js', {
'course_id': self.course_id
}))
fragment.add_content(render_mustache_templates())
for url in get_js_urls():
fragment.add_javascript_url(url)
for url in get_css_urls():
fragment.add_css_url(url)
fragment.initialize_js('DiscussionCourseBlock')
return fragment
def _student_view_studio(self):
""" Renders student view for Studio """
fragment = Fragment()
fragment.add_content(render_mako_template('discussion/_discussion_course_studio.html'))
fragment.add_css_url(asset_to_static_url('xblock/discussion/css/discussion-studio.css'))
return fragment
def studio_view(self, context=None): # pylint: disable=unused-argument
""" Renders author view Studio """
return Fragment()
var $$course_id = "{{course_id}}";
function DiscussionCourseBlock(runtime, element) {
var el = $(element).find('section.discussion');
el.data('pushState', 'false');
var testUrl = runtime.handlerUrl(element, 'test');
if (testUrl.match(/^(http|https):\/\//)) {
var hostname = testUrl.match(/^(.*:\/\/[a-z\-.]+)\//)[1];
DiscussionUtil.setBaseUrl(hostname);
}
}
var $$course_id = "{{course_id}}";
function DiscussionInlineBlock(runtime, element) {
var el = $(element).find('.discussion-module');
var testUrl = runtime.handlerUrl(element, 'test');
if (testUrl.match(/^(http|https):\/\//)) {
var hostname = testUrl.match(/^(.*:\/\/[a-z\-.]+)\//)[1];
DiscussionUtil.setBaseUrl(hostname);
}
new DiscussionModuleView({
el: el,
async_thread_views: true
});
}
import os
import pkg_resources
from django.templatetags.static import static
from edxmako.shortcuts import render_to_string
from django.template import Context, Template
from django.conf import settings
from mako.template import Template as MakoTemplate
JS_URLS = [
# VENDOR
'js/vendor/URI.min.js',
'js/vendor/jquery.leanModal.min.js',
'js/vendor/jquery.timeago.js',
'js/vendor/underscore-min.js',
'js/vendor/backbone-min.js',
'js/vendor/mustache.js',
'xblock/discussion/js/vendor/split.js',
'xblock/discussion/js/vendor/i18n.js',
'xblock/discussion/js/vendor/Markdown.Converter.js',
'xblock/discussion/js/vendor/Markdown.Sanitizer.js',
'xblock/discussion/js/vendor/Markdown.Editor.js',
'xblock/discussion/js/vendor/mathjax_delay_renderer.js',
'xblock/discussion/js/vendor/customwmd.js',
]
CSS_URLS = [
# 'xblock/discussion/css/discussion-app.css',
'xblock/discussion/css/vendor/font-awesome.css'
]
def load_resource(resource_path):
"""
Gets the content of a resource
"""
resource_content = pkg_resources.resource_string('discussion_forum', resource_path)
return unicode(resource_content)
def render_template(template_path, context=None):
"""
Evaluate a template by resource path, applying the provided context
"""
template_str = load_resource(template_path)
template = Template(template_str)
return template.render(Context(context if context else {}))
def render_mako_template(template_path, context=None):
"""
Evaluate a mako template by resource path, applying the provided context
"""
return render_to_string(template_path, context if context else {})
def render_mustache_templates():
""" Renders all mustache templates as script tags """
mustache_dir = settings.COMMON_ROOT / 'templates' / 'discussion' / 'mustache'
def is_valid_file_name(file_name):
""" Checks if file is a mustache template """
return file_name.endswith('.mustache')
def read_file(file_name):
""" Reads file and decodes it's content """
return open(mustache_dir + '/' + file_name, "r").read().decode('utf-8')
def template_id_from_file_name(file_name):
""" Generates template_id from file name """
return file_name.rpartition('.')[0]
def process_mako(template_content):
""" Creates and renders Mako template """
return MakoTemplate(template_content).render_unicode()
def make_script_tag(script_id, content):
""" Wraps content in script tag """
return u"<script type='text/template' id='{0}'>{1}</script>".format(script_id, content)
return u'\n'.join(
make_script_tag(template_id_from_file_name(file_name), process_mako(read_file(file_name)))
for file_name in os.listdir(mustache_dir)
if is_valid_file_name(file_name)
)
def get_scenarios_from_path(scenarios_path, include_identifier=False):
"""
Returns an array of (title, xmlcontent) from files contained in a specified directory,
formatted as expected for the return value of the workbench_scenarios() method
"""
base_fullpath = os.path.dirname(os.path.realpath(__file__))
scenarios_fullpath = os.path.join(base_fullpath, scenarios_path)
scenarios = []
if os.path.isdir(scenarios_fullpath):
for template in os.listdir(scenarios_fullpath):
if not template.endswith('.xml'):
continue
identifier = template[:-4]
title = identifier.replace('_', ' ').title()
template_path = os.path.join(scenarios_path, template)
if not include_identifier:
scenarios.append((title, load_resource(template_path)))
else:
scenarios.append((identifier, title, load_resource(template_path)))
return scenarios
def load_scenarios_from_path(scenarios_path):
"""
Load all xml files contained in a specified directory, as workbench scenarios
"""
return get_scenarios_from_path(scenarios_path, include_identifier=True)
def get_js_urls():
""" Returns a list of all additional javascript files """
return [asset_to_static_url(path) for path in JS_URLS]
def get_css_urls():
""" Returns a list of all additional css files """
return [asset_to_static_url(path) for path in CSS_URLS]
def asset_to_static_url(asset_path):
"""
:param str asset_path: path to asset
:return: str|unicode url of asset
"""
return static(asset_path)
"""Setup for discussion-forum XBlock."""
import os
from setuptools import setup
def package_data(pkg, root_list):
"""Generic function to find package_data for `pkg` under `root`."""
data = []
for root in root_list:
for dirname, _, files in os.walk(os.path.join(pkg, root)):
for fname in files:
data.append(os.path.relpath(os.path.join(dirname, fname), pkg))
return {pkg: data}
setup(
name='xblock-discussion',
version='0.1',
description='XBlock - Discussion Forum',
packages=[
'discussion_forum'
],
install_requires=[
'XBlock',
],
entry_points={
'xblock.v1': [
'discussion-forum = discussion_forum:DiscussionXBlock',
'discussion-course = discussion_forum:DiscussionCourseXBlock'
]
},
package_data=package_data("discussion_forum", ["static"]),
)
......@@ -55,9 +55,9 @@ class DiscussionModule(DiscussionFields, XModule):
'course': self.get_course(),
}
if getattr(self.system, 'is_author_mode', False):
template = '/discussion/_discussion_inline_studio.html'
template = 'discussion/_discussion_inline_studio.html'
else:
template = '/discussion/_discussion_inline.html'
template = 'discussion/_discussion_inline.html'
return self.system.render_template(template, context)
def get_course(self):
......
......@@ -192,6 +192,24 @@ class XModuleMixin(XBlockMixin):
default=None
)
def __init__(self, *args, **kwargs):
self.xmodule_runtime = None
super(XModuleMixin, self).__init__(*args, **kwargs)
@property
def runtime(self):
# Handle XModule backwards compatibility. If this is a pure
# XBlock, and it has an xmodule_runtime defined, then we're in
# an XModule context, not an XModuleDescriptor context,
# so we should use the xmodule_runtime (ModuleSystem) as the runtime.
if not isinstance(self, (XModule, XModuleDescriptor)) and getattr(self, 'xmodule_runtime', None) is not None:
return self.xmodule_runtime
return self._runtime
@runtime.setter
def runtime(self, value):
self._runtime = value
@property
def system(self):
"""
......@@ -232,7 +250,7 @@ class XModuleMixin(XBlockMixin):
name = self.display_name
if name is None:
name = self.url_name.replace('_', ' ')
return name
return name.replace('<', '&lt;').replace('>', '&gt;')
@property
def xblock_kvs(self):
......@@ -725,7 +743,7 @@ class XModuleDescriptor(XModuleMixin, HTMLSnippet, ResourceTemplates, XBlock):
# leaving off original_version since it complicates creation w/o any obv value yet and is computable
# by following previous until None
# definition_locator is only used by mongostores which separate definitions from blocks
self.edited_by = self.edited_on = self.previous_version = self.update_version = self.definition_locator = None
self.previous_version = self.update_version = self.definition_locator = None
self.xmodule_runtime = None
@classmethod
......@@ -1169,9 +1187,8 @@ class DescriptorSystem(MetricsMixin, ConfigurableFragmentWrapper, Runtime): # p
return super(DescriptorSystem, self).render(block, view_name, context)
def handler_url(self, block, handler_name, suffix='', query='', thirdparty=False):
xmodule_runtime = getattr(block, 'xmodule_runtime', None)
if xmodule_runtime is not None:
return xmodule_runtime.handler_url(block, handler_name, suffix, query, thirdparty)
if block.xmodule_runtime is not None:
return block.xmodule_runtime.handler_url(block, handler_name, suffix, query, thirdparty)
else:
# Currently, Modulestore is responsible for instantiating DescriptorSystems
# This means that LMS/CMS don't have a way to define a subclass of DescriptorSystem
......@@ -1183,9 +1200,8 @@ class DescriptorSystem(MetricsMixin, ConfigurableFragmentWrapper, Runtime): # p
"""
See :meth:`xblock.runtime.Runtime:local_resource_url` for documentation.
"""
xmodule_runtime = getattr(block, 'xmodule_runtime', None)
if xmodule_runtime is not None:
return xmodule_runtime.local_resource_url(block, uri)
if block.xmodule_runtime is not None:
return block.xmodule_runtime.local_resource_url(block, uri)
else:
# Currently, Modulestore is responsible for instantiating DescriptorSystems
# This means that LMS/CMS don't have a way to define a subclass of DescriptorSystem
......@@ -1203,9 +1219,8 @@ class DescriptorSystem(MetricsMixin, ConfigurableFragmentWrapper, Runtime): # p
"""
See :meth:`xblock.runtime.Runtime:publish` for documentation.
"""
xmodule_runtime = getattr(block, 'xmodule_runtime', None)
if xmodule_runtime is not None:
return xmodule_runtime.publish(block, event_type, event)
if block.xmodule_runtime is not None:
return block.xmodule_runtime.publish(block, event_type, event)
def add_block_as_child_node(self, block, node):
child = etree.SubElement(node, "unknown")
......
(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) {
spyOn($, 'ajax').andCallFake(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('other_topic');
expect(params.data.title).toBe('changed thread title');
params.success();
return {always: function() {}};
});
view.$el.find('a.topic-title')[1].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('other_topic');
expect(thread.get('courseware_title')).toBe('Other Topic');
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);
});
it('can save new data correctly in inline mode', function() {
this.createEditView({"mode": "inline"});
testUpdate(this.view, this.thread);
});
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);
});
});
}).call(this);
!views/discussion_thread_edit_view.js
!views/discussion_topic_menu_view.js
......@@ -67,31 +67,21 @@ if Backbone?
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)
#
if thread.get('pinned')
#use tomorrow's date
today = new Date();
new Date(today.getTime() + (24 * 60 * 60 * 1000));
else
thread.get("created_at")
#
# 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)
#
if thread.get('pinned')
#use tomorrow's date
today = new Date();
-(new Date(today.getTime() + (24 * 60 * 60 * 1000)));
else
-(new Date(thread.get("created_at")).getTime())
#
# 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()))
......@@ -100,15 +90,42 @@ if Backbone?
sortByVotes: (thread1, thread2) ->
thread1_count = parseInt(thread1.get("votes")['up_count'])
thread2_count = parseInt(thread2.get("votes")['up_count'])
if thread2_count != thread1_count
thread2_count - thread1_count
else
thread2.created_at_time() - thread1.created_at_time()
@pinnedThreadsSortComparatorWithCount(thread1, thread2, thread1_count, thread2_count)
sortByComments: (thread1, thread2) ->
thread1_count = parseInt(thread1.get("comments_count"))
thread2_count = parseInt(thread2.get("comments_count"))
if thread2_count != thread1_count
thread2_count - thread1_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 date
threadCreatedTime = new Date(thread.get("created_at")).getTime()
if thread.get('pinned')
#use tomorrow's date
today = new Date();
preferredDate = new Date(today.getTime() + (24 * 60 * 60 * 1000) + threadCreatedTime);
else
preferredDate = threadCreatedTime
if ascending
preferredDate
else
thread2.created_at_time() - thread1.created_at_time()
-(preferredDate)
......@@ -7,12 +7,11 @@ if Backbone?
"click .new-post-btn": "toggleNewPost"
"keydown .new-post-btn":
(event) -> DiscussionUtil.activateOnSpace(event, @toggleNewPost)
"click .cancel": "hideNewPost"
"click .discussion-paginator a": "navigateToPage"
paginationTemplate: -> DiscussionUtil.getTemplate("_pagination")
page_re: /\?discussion_page=(\d+)/
initialize: ->
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)
......@@ -20,6 +19,7 @@ if Backbone?
@page = parseInt(match[1])
else
@page = 1
@options = options
toggleNewPost: (event) =>
event.preventDefault()
......@@ -36,9 +36,8 @@ if Backbone?
@$("section.discussion").slideDown()
@showed = true
hideNewPost: (event) ->
event.preventDefault()
@newPostForm.slideUp(300)
hideNewPost: =>
@newPostForm.slideUp(300)
hideDiscussion: =>
@$("section.discussion").slideUp()
......@@ -100,8 +99,19 @@ if Backbone?
@$el.append($discussion)
@newPostForm = $('.new-post-article')
@threadviews = @discussion.map (thread) ->
new DiscussionThreadView el: @$("article#thread_#{thread.id}"), model: thread, mode: "inline"
async = @options.async_thread_views
@threadviews = @discussion.map (thread) =>
view = new DiscussionThreadView(
el: @$("article#thread_#{thread.id}"),
model: thread,
mode: "inline",
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(
......@@ -111,6 +121,7 @@ if Backbone?
topicId: discussionId
)
@newPostView.render()
@listenTo( @newPostView, 'newPost:cancel', @hideNewPost )
@discussion.on "add", @addThread
@retrieved = true
......@@ -124,7 +135,14 @@ if Backbone?
# 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"
threadView = new DiscussionThreadView(
el: article,
model: thread,
mode: "inline",
course_settings: @course_settings,
topicId: @$el.data("discussion-id")
)
threadView.render()
@threadviews.unshift threadView
......
......@@ -8,7 +8,11 @@ if Backbone?
@discussion = options['discussion']
@course_settings = options['course_settings']
@nav = new DiscussionThreadListView(collection: @discussion, el: $(".forum-nav"))
@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
......@@ -23,9 +27,9 @@ if Backbone?
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)
@newPostView.$('.cancel').bind "click", @hideNewPost
allThreads: ->
@nav.updateSidebar()
......@@ -42,6 +46,9 @@ if Backbone?
@thread.set("unread_comments_count", 0)
@thread.set("read", true)
@setActiveThread()
@showMain()
showMain: =>
if(@main)
@main.cleanup()
@main.undelegateEvents()
......@@ -50,10 +57,16 @@ 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()
@thread.on "thread:thread_type_updated", @showMain
navigateToThread: (thread_id) =>
thread = @discussion.get(thread_id)
......@@ -70,10 +83,9 @@ if Backbone?
$('.new-post-title').focus()
)
hideNewPost: (event) =>
hideNewPost: =>
@newPost.fadeOut(
duration: 200
complete: =>
$('.forum-content').fadeIn(200)
)
if Backbone?
DiscussionApp =
start: (elem)->
start: (elem, pushState = true)->
# TODO: Perhaps eliminate usage of global variables when possible
DiscussionUtil.loadRolesFromContainer()
element = $(elem)
......@@ -17,7 +17,7 @@ if Backbone?
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/"})
Backbone.history.start({pushState: pushState, root: "/courses/#{$$course_id}/discussion/forum/"})
DiscussionProfileApp =
start: (elem) ->
# Roles are not included in user profile page, but they are not used for anything
......@@ -32,6 +32,7 @@ if Backbone?
new DiscussionUserProfileView(el: element, collection: threads, page: page, numPages: numPages)
$ ->
$("section.discussion").each (index, elem) ->
DiscussionApp.start(elem)
pushState = $(elem).data('pushState') != 'false'
DiscussionApp.start(elem, pushState)
$("section.discussion-user-threads").each (index, elem) ->
DiscussionProfileApp.start(elem)
......@@ -194,7 +194,7 @@ if Backbone?
url = @model.urlFor("endorse")
updates =
endorsed: is_endorsing
endorsement: if is_endorsing then {username: DiscussionUtil.getUser().get("username"), time: new Date().toISOString()} else null
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.")
......
if Backbone?
class @DiscussionThreadEditView extends Backbone.View
class @DiscussionThreadEditView extends Backbone.View
tagName: 'form',
events:
"click .post-update": "update"
"click .post-cancel": "cancel_edit"
'submit': 'updateHandler',
'click .post-cancel': 'cancelHandler'
attributes:
'class': 'discussion-post edit-post-form'
initialize: (options) =>
@container = options.container || $('.thread-content-wrapper')
@mode = options.mode || 'inline'
@course_settings = options.course_settings
@threadType = @model.get('thread_type')
@topicId = @model.get('commentable_id')
_.bindAll(this)
return this
render: () =>
formId = _.uniqueId("form-")
@template = _.template($('#thread-edit-template').html())
@$el.html(@template(@model.toJSON())).appendTo(@container)
@submitBtn = @$('.post-update')
threadTypeTemplate = _.template($("#thread-type-template").html())
@addField(threadTypeTemplate({form_id: formId}))
@$("#" + formId + "-post-type-" + @threadType).attr('checked', true)
@topicView = new DiscussionTopicMenuView({
topicId: @topicId,
course_settings: @course_settings
})
@addField(@topicView.render())
DiscussionUtil.makeWmdEditor(@$el, $.proxy(@$, this), 'edit-post-body')
return this
addField: (fieldView) =>
@$('.forum-edit-post-form-wrapper').append(fieldView)
return this
isTabMode: () =>
@mode == 'tab'
$: (selector) ->
@$el.find(selector)
save: () =>
title = @$('.edit-post-title').val()
threadType = @$(".post-type-input:checked").val()
body = @$('.edit-post-body textarea').val()
initialize: ->
super()
commentableId = @topicView.getCurrentTopicId()
postData = {
title: title,
thread_type: threadType,
body: body,
commentable_id: commentableId
}
render: ->
@template = _.template($("#thread-edit-template").html())
@$el.html(@template(@model.toJSON()))
@delegateEvents()
DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), "edit-post-body"
@
DiscussionUtil.safeAjax({
$elem: @submitBtn,
$loading: @submitBtn,
url: DiscussionUtil.urlFor('update_thread', @model.id),
type: 'POST',
dataType: 'json',
async: false, # @TODO when the rest of the stuff below is made to work properly..
data: postData,
error: DiscussionUtil.formErrorHandler(@$('.post-errors')),
success: =>
# @TODO: Move this out of the callback, this makes it feel sluggish
@$('.edit-post-title').val('').attr('prev-text', '');
@$('.edit-post-body textarea').val('').attr('prev-text', '');
@$('.wmd-preview p').html('');
postData.courseware_title = @topicView.getFullTopicName();
@model.set(postData).unset('abbreviatedBody');
@trigger('thread:updated');
if (@threadType != threadType)
@model.trigger('thread:thread_type_updated')
@trigger('comment:endorse')
})
update: (event) ->
@trigger "thread:update", event
updateHandler: (event) =>
event.preventDefault()
# this event is for the moment triggered and used nowhere.
@trigger('thread:update', event)
@save()
return this
cancel_edit: (event) ->
@trigger "thread:cancel_edit", event
cancelHandler: (event) =>
event.preventDefault()
@trigger("thread:cancel_edit", event)
@remove()
return this
// Generated by CoffeeScript 1.6.1
(function() {
var _this = this,
__hasProp = {}.hasOwnProperty,
__extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; };
if (typeof Backbone !== "undefined" && Backbone !== null) {
this.DiscussionThreadEditView = (function(_super) {
__extends(DiscussionThreadEditView, _super);
function DiscussionThreadEditView() {
var _this = this;
this.cancelHandler = function(event) {
return DiscussionThreadEditView.prototype.cancelHandler.apply(_this, arguments);
};
this.updateHandler = function(event) {
return DiscussionThreadEditView.prototype.updateHandler.apply(_this, arguments);
};
this.save = function() {
return DiscussionThreadEditView.prototype.save.apply(_this, arguments);
};
this.isTabMode = function() {
return DiscussionThreadEditView.prototype.isTabMode.apply(_this, arguments);
};
this.addField = function(fieldView) {
return DiscussionThreadEditView.prototype.addField.apply(_this, arguments);
};
this.render = function() {
return DiscussionThreadEditView.prototype.render.apply(_this, arguments);
};
this.initialize = function(options) {
return DiscussionThreadEditView.prototype.initialize.apply(_this, arguments);
};
return DiscussionThreadEditView.__super__.constructor.apply(this, arguments);
}
DiscussionThreadEditView.prototype.tagName = 'form';
DiscussionThreadEditView.prototype.events = {
'submit': 'updateHandler',
'click .post-cancel': 'cancelHandler'
};
DiscussionThreadEditView.prototype.attributes = {
'class': 'discussion-post edit-post-form'
};
DiscussionThreadEditView.prototype.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');
_.bindAll(this);
return this;
};
DiscussionThreadEditView.prototype.render = function() {
var formId, 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);
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;
};
DiscussionThreadEditView.prototype.addField = function(fieldView) {
this.$('.forum-edit-post-form-wrapper').append(fieldView);
return this;
};
DiscussionThreadEditView.prototype.isTabMode = function() {
return this.mode === 'tab';
};
DiscussionThreadEditView.prototype.save = function() {
var body, commentableId, postData, threadType, title,
_this = this;
title = this.$('.edit-post-title').val();
threadType = this.$(".post-type-input:checked").val();
body = this.$('.edit-post-body textarea').val();
commentableId = this.topicView.getCurrentTopicId();
postData = {
title: title,
thread_type: threadType,
body: body,
commentable_id: commentableId
};
return DiscussionUtil.safeAjax({
$elem: this.submitBtn,
$loading: this.submitBtn,
url: DiscussionUtil.urlFor('update_thread', this.model.id),
type: 'POST',
dataType: 'json',
async: false,
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('');
postData.courseware_title = _this.topicView.getFullTopicName();
_this.model.set(postData).unset('abbreviatedBody');
_this.trigger('thread:updated');
if (_this.threadType !== threadType) {
_this.model.trigger('thread:thread_type_updated');
return _this.trigger('comment:endorse');
}
}
});
};
DiscussionThreadEditView.prototype.updateHandler = function(event) {
event.preventDefault();
this.trigger('thread:update', event);
this.save();
return this;
};
DiscussionThreadEditView.prototype.cancelHandler = function(event) {
event.preventDefault();
this.trigger("thread:cancel_edit", event);
this.remove();
return this;
};
return DiscussionThreadEditView;
})(Backbone.View);
}
}).call(this);
......@@ -5,15 +5,17 @@ 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"
"click .icon-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: ->
initialize: (options) ->
@courseSettings = options.courseSettings
@displayedCollection = new Discussion(@collection.models, pages: @collection.pages)
@collection.on "change", @reloadDisplayedCollection
@discussionIds=""
......@@ -121,15 +123,20 @@ if Backbone?
render: ->
@timer = 0
@$el.html(@template())
@$el.html(
@template({
isCohorted: @courseSettings.get("is_cohorted"),
isPrivilegedUser: DiscussionUtil.isPrivilegedUser()
})
)
@$(".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()
@
......@@ -179,10 +186,9 @@ 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"
when 'commentables'
options.commentable_ids = @discussionIds
if @group_id
......@@ -190,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
......@@ -256,7 +261,7 @@ if Backbone?
else
$('input.email-setting').removeAttr('checked')
thread_id = null
@trigger("thread:removed")
@trigger("thread:removed")
#select all threads
isBrowseMenuVisible: =>
......@@ -353,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 = ""
......@@ -382,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
......@@ -397,7 +405,7 @@ if Backbone?
if callback?
callback()
retrieveDiscussions: (discussion_ids) ->
@discussionIds = discussion_ids.join(',')
@mode = 'commentables'
......@@ -418,7 +426,8 @@ if Backbone?
@retrieveFirstPage(event)
performSearch: (event) ->
if event.which == 13
#event.which 13 represent the Enter button
if event.which == 13 or event.type == 'click'
event.preventDefault()
@hideBrowseMenu()
@setCurrentTopicDisplay(gettext("Search Results"))
......
......@@ -21,12 +21,35 @@ 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)
@async_thread_views = if options.async_thread_views? then options.async_thread_views else false
# 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: @course_settings
topicId: @topicId
)
@render()
renderTemplate: ->
@template = _.template($("#thread-template").html())
@template(@model.toJSON())
......@@ -55,6 +78,7 @@ if Backbone?
attrRenderer: $.extend({}, DiscussionContentView.prototype.attrRenderer, {
closed: (closed) ->
@$(".discussion-reply-new").toggle(not closed)
@$('.comment-form').closest('li').toggle(not closed)
@renderAddResponseButton()
})
......@@ -120,7 +144,9 @@ if Backbone?
)
@trigger "thread:responses:rendered"
@loadedResponses = true
error: (xhr) =>
error: (xhr, textStatus) =>
return if textStatus == 'abort'
if xhr.status == 404
DiscussionUtil.discussionAlert(
gettext("Sorry"),
......@@ -199,7 +225,7 @@ if Backbone?
renderResponseToList: (response, listSelector, options) =>
response.set('thread', @model)
view = new ThreadResponseView($.extend({model: response}, options))
view = new ThreadResponseView($.extend({model: response, async: @async_thread_views}, options))
view.on "comment:add", @addComment
view.on "comment:endorse", @endorseThread
view.render()
......@@ -251,49 +277,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
)
@editView.bind "thread:updated thread:cancel_edit", @closeEditView
@editView.bind "comment:endorse", @endorseThread
renderSubView: (view) ->
view.setElement(@$('.thread-content-wrapper'))
......@@ -301,15 +298,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
......@@ -317,10 +308,12 @@ if Backbone?
renderShowView: () ->
@renderSubView(@showView)
cancelEdit: (event) =>
event.preventDefault()
closeEditView: (event) =>
@createShowView()
@renderShowView()
# 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
......
(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 = $('<div>'),
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);
......@@ -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,32 +15,26 @@ 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())
threadTypeTemplate = _.template($("#thread-type-template").html());
@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"
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.isStaff()
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}
......@@ -50,17 +43,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)
......@@ -75,13 +66,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)
......@@ -102,100 +94,27 @@ if Backbone?
success: (response, textStatus) =>
# TODO: Move this out of the callback, this makes it feel sluggish
thread = new Thread response['content']
DiscussionUtil.clearFormErrors(@$(".post-errors"))
@$el.hide()
@$(".js-post-title").val("").attr("prev-text", "")
@$(".js-post-body textarea").val("").attr("prev-text", "")
@$(".wmd-preview p").html("") # only line not duplicated in new post inline view
@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 = $("<div>")
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.")
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
)
......@@ -12,6 +12,7 @@ if Backbone?
initialize: (options) ->
@collapseComments = options.collapseComments
@async = if options.async? then options.async else false;
@createShowView()
renderTemplate: ->
......@@ -28,6 +29,8 @@ if Backbone?
@renderShowView()
@renderAttrs()
if @model.get("thread").get("closed")
@hideCommentForm()
@renderComments()
@
......@@ -195,7 +198,7 @@ if Backbone?
url: url
type: "POST"
dataType: 'json'
async: false # TODO when the rest of the stuff below is made to work properly..
async: @async # TODO when the rest of the stuff below is made to work properly..
data:
body: newBody
error: DiscussionUtil.formErrorHandler(@$(".edit-post-form-errors"))
......
/* Some fixes that should only be applied when using discussion as an xblock */
.discussion-body .sort-bar li {
margin: 4px 0px 0 0 !important;
}
.discussion-body .post-list a .title {
width: 60% !important;
}
div.discussion-body div.forum-nav div.forum-nav-thread-list-wrapper ul.forum-nav-thread-list,
div.discussion-body div.forum-nav div.forum-nav-browse-menu-wrapper ul.forum-nav-browse-menu,
div.discussion-body div.forum-nav div.forum-nav-thread-list-wrapper ul.forum-nav-thread-list ul
{
padding-left: 0;
list-style: none;
}
div.discussion-body div.forum-nav div.forum-nav-browse-menu-wrapper ul.forum-nav-browse-submenu {
list-style: none
}
div.discussion-body div.forum-nav div.forum-nav-thread-list-wrapper ul.forum-nav-thread-list li {
margin-bottom: 0;
}
.discussion-module {
display: block;
border: none;
box-shadow: none;
line-height: 1.4;
position: relative;
margin: 20px 0;
padding: 20px;
background: #f6f6f6;
border-radius: 3px;
}
This source diff could not be displayed because it is too large. You can view the blob instead.
function DiscussionEditBlock(runtime, element) {
$('.save-button').bind('click', function() {
var data = {
'display_name': $('#display-name').val(),
'discussion_category': $('#discussion-category').val(),
'discussion_target': $('#discussion-target').val()
};
var handlerUrl = runtime.handlerUrl(element, 'studio_submit');
$.post(handlerUrl, JSON.stringify(data)).complete(function() {
window.location.reload(false);
});
});
$('.cancel-button').bind('click', function() {
runtime.notify('cancel', {});
});
}
(function () {
var output, Converter;
if (typeof exports === "object" && typeof require === "function") { // we're in a CommonJS (e.g. Node.js) module
output = exports;
Converter = require("./Markdown.Converter").Converter;
} else {
output = window.Markdown;
Converter = output.Converter;
}
output.getSanitizingConverter = function () {
var converter = new Converter();
converter.hooks.chain("postConversion", sanitizeHtml);
converter.hooks.chain("postConversion", balanceTags);
return converter;
}
function sanitizeHtml(html) {
return html.replace(/<[^>]*>?/gi, sanitizeTag);
}
// (tags that can be opened/closed) | (tags that stand alone)
var basic_tag_whitelist = /^(<\/?(b|blockquote|code|del|dd|dl|dt|em|h1|h2|h3|i|kbd|li|ol|p|pre|s|sup|sub|strong|strike|ul)>|<(br|hr)\s?\/?>)$/i;
// <a href="url..." optional title>|</a>
var a_white = /^(<a\shref="((https?|ftp):\/\/|\/)[-A-Za-z0-9+&@#\/%?=~_|!:,.;\(\)]+"(\stitle="[^"<>]+")?\s?>|<\/a>)$/i;
// <img src="url..." optional width optional height optional alt optional title
var img_white = /^(<img\ssrc="(https?:\/\/|\/)[-A-Za-z0-9+&@#\/%?=~_|!:,.;\(\)]+"(\swidth="\d{1,3}")?(\sheight="\d{1,3}")?(\salt="[^"<>]*")?(\stitle="[^"<>]*")?\s?\/?>)$/i;
function sanitizeTag(tag) {
if (tag.match(basic_tag_whitelist) || tag.match(a_white) || tag.match(img_white))
return tag;
else
return "";
}
/// <summary>
/// attempt to balance HTML tags in the html string
/// by removing any unmatched opening or closing tags
/// IMPORTANT: we *assume* HTML has *already* been
/// sanitized and is safe/sane before balancing!
///
/// adapted from CODESNIPPET: A8591DBA-D1D3-11DE-947C-BA5556D89593
/// </summary>
function balanceTags(html) {
if (html == "")
return "";
var re = /<\/?\w+[^>]*(\s|$|>)/g;
// convert everything to lower case; this makes
// our case insensitive comparisons easier
var tags = html.toLowerCase().match(re);
// no HTML tags present? nothing to do; exit now
var tagcount = (tags || []).length;
if (tagcount == 0)
return html;
var tagname, tag;
var ignoredtags = "<p><img><br><li><hr>";
var match;
var tagpaired = [];
var tagremove = [];
var needsRemoval = false;
// loop through matched tags in forward order
for (var ctag = 0; ctag < tagcount; ctag++) {
tagname = tags[ctag].replace(/<\/?(\w+).*/, "$1");
// skip any already paired tags
// and skip tags in our ignore list; assume they're self-closed
if (tagpaired[ctag] || ignoredtags.search("<" + tagname + ">") > -1)
continue;
tag = tags[ctag];
match = -1;
if (!/^<\//.test(tag)) {
// this is an opening tag
// search forwards (next tags), look for closing tags
for (var ntag = ctag + 1; ntag < tagcount; ntag++) {
if (!tagpaired[ntag] && tags[ntag] == "</" + tagname + ">") {
match = ntag;
break;
}
}
}
if (match == -1)
needsRemoval = tagremove[ctag] = true; // mark for removal
else
tagpaired[match] = true; // mark paired
}
if (!needsRemoval)
return html;
// delete all orphaned tags from the string
var ctag = 0;
html = html.replace(re, function (match) {
var res = tagremove[ctag] ? "" : match;
ctag++;
return res;
});
return html;
}
})();
// Generated by CoffeeScript 1.6.1
(function() {
$(function() {
var HUB, MathJaxProcessor;
if (typeof MathJax === "undefined" || MathJax === null) {
return;
}
HUB = MathJax.Hub;
MathJaxProcessor = (function() {
var CODESPAN, MATHSPLIT;
MATHSPLIT = /(\$\$?|\\(?:begin|end)\{[a-z]*\*?\}|\\[\\{}$]|[{}]|(?:\n\s*)+|@@\d+@@)/i;
CODESPAN = /(^|[^\\])(`+)([^\n]*?[^`\n])\2(?!`)/gm;
function MathJaxProcessor(inlineMark, displayMark) {
this.inlineMark = inlineMark || "$";
this.displayMark = displayMark || "$$";
this.math = null;
this.blocks = null;
}
MathJaxProcessor.prototype.processMath = function(start, last, preProcess) {
var block, i, _i, _ref;
block = this.blocks.slice(start, last + 1).join("").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
if (HUB.Browser.isMSIE) {
block = block.replace(/(%[^\n]*)\n/g, "$1<br/>\n");
}
for (i = _i = _ref = start + 1; _ref <= last ? _i <= last : _i >= last; i = _ref <= last ? ++_i : --_i) {
this.blocks[i] = "";
}
this.blocks[start] = "@@" + this.math.length + "@@";
if (preProcess) {
block = preProcess(block);
}
return this.math.push(block);
};
MathJaxProcessor.prototype.removeMath = function(text) {
var block, braces, current, deTilde, end, hasCodeSpans, last, start, _i, _ref;
text = text || "";
this.math = [];
start = end = last = null;
braces = 0;
hasCodeSpans = /`/.test(text);
if (hasCodeSpans) {
text = text.replace(/~/g, "~T").replace(CODESPAN, function($0) {
return $0.replace(/\$/g, "~D");
});
deTilde = function(text) {
return text.replace(/~([TD])/g, function($0, $1) {
return {
T: "~",
D: "$"
}[$1];
});
};
} else {
deTilde = function(text) {
return text;
};
}
this.blocks = _split(text.replace(/\r\n?/g, "\n"), MATHSPLIT);
for (current = _i = 1, _ref = this.blocks.length; _i < _ref; current = _i += 2) {
block = this.blocks[current];
if (block.charAt(0) === "@") {
this.blocks[current] = "@@" + this.math.length + "@@";
this.math.push(block);
} else if (start) {
if (block === end) {
if (braces) {
last = current;
} else {
this.processMath(start, current, deTilde);
start = end = last = null;
}
} else if (block.match(/\n.*\n/)) {
if (last) {
current = last;
this.processMath(start, current, deTilde);
}
start = end = last = null;
braces = 0;
} else if (block === "{") {
++braces;
} else if (block === "}" && braces) {
--braces;
}
} else {
if (block === this.inlineMark || block === this.displayMark) {
start = current;
end = block;
braces = 0;
} else if (block.substr(1, 5) === "begin") {
start = current;
end = "\\end" + block.substr(6);
braces = 0;
}
}
}
if (last) {
this.processMath(start, last, deTilde);
start = end = last = null;
}
return deTilde(this.blocks.join(""));
};
MathJaxProcessor.removeMathWrapper = function(_this) {
return function(text) {
return _this.removeMath(text);
};
};
MathJaxProcessor.prototype.replaceMath = function(text) {
var _this = this;
text = text.replace(/@@(\d+)@@/g, function($0, $1) {
return _this.math[$1];
});
this.math = null;
return text;
};
MathJaxProcessor.replaceMathWrapper = function(_this) {
return function(text) {
return _this.replaceMath(text);
};
};
return MathJaxProcessor;
})();
if (typeof Markdown !== "undefined" && Markdown !== null) {
Markdown.getMathCompatibleConverter = function(postProcessor) {
var converter, processor;
postProcessor || (postProcessor = (function(text) {
return text;
}));
converter = Markdown.getSanitizingConverter();
processor = new MathJaxProcessor();
converter.hooks.chain("preConversion", MathJaxProcessor.removeMathWrapper(processor));
converter.hooks.chain("postConversion", function(text) {
return postProcessor(MathJaxProcessor.replaceMathWrapper(processor)(text));
});
return converter;
};
return Markdown.makeWmdEditor = function(elem, appended_id, imageUploadUrl, postProcessor) {
var $elem, $wmdPanel, $wmdPreviewContainer, ajaxFileUpload, converter, delayRenderer, editor, imageUploadHandler, initialText, wmdInputId, _append;
$elem = $(elem);
if (!$elem.length) {
console.log("warning: elem for makeWmdEditor doesn't exist");
return;
}
if (!$elem.find(".wmd-panel").length) {
initialText = $elem.html();
$elem.empty();
_append = appended_id || "";
wmdInputId = "wmd-input" + _append;
$wmdPreviewContainer = $("<div>").addClass("wmd-preview-container").append($("<div>").addClass("wmd-preview-label").text(gettext("Preview"))).append($("<div>").attr("id", "wmd-preview" + _append).addClass("wmd-panel wmd-preview"));
$wmdPanel = $("<div>").addClass("wmd-panel").append($("<div>").attr("id", "wmd-button-bar" + _append)).append($("<label>").addClass("sr").attr("for", wmdInputId).text(gettext("Post body"))).append($("<textarea>").addClass("wmd-input").attr("id", wmdInputId).html(initialText)).append($wmdPreviewContainer);
$elem.append($wmdPanel);
}
converter = Markdown.getMathCompatibleConverter(postProcessor);
ajaxFileUpload = function(imageUploadUrl, input, startUploadHandler) {
$("#loading").ajaxStart(function() {
return $(this).show();
}).ajaxComplete(function() {
return $(this).hide();
});
$("#upload").ajaxStart(function() {
return $(this).hide();
}).ajaxComplete(function() {
return $(this).show();
});
return $.ajaxFileUpload({
url: imageUploadUrl,
secureuri: false,
fileElementId: 'file-upload',
dataType: 'json',
success: function(data, status) {
var error, fileURL;
fileURL = data['result']['file_url'];
error = data['result']['error'];
if (error !== '') {
alert(error);
if (startUploadHandler) {
$('#file-upload').unbind('change').change(startUploadHandler);
}
return console.log(error);
} else {
return $(input).attr('value', fileURL);
}
},
error: function(data, status, e) {
alert(e);
if (startUploadHandler) {
return $('#file-upload').unbind('change').change(startUploadHandler);
}
}
});
};
imageUploadHandler = function(elem, input) {
return ajaxFileUpload(imageUploadUrl, input, imageUploadHandler);
};
editor = new Markdown.Editor(converter, appended_id, null, imageUploadHandler);
delayRenderer = new MathJaxDelayRenderer();
editor.hooks.chain("onPreviewPush", function(text, previewSet) {
return delayRenderer.render({
text: text,
previewSetter: previewSet
});
});
editor.run();
return editor;
};
}
});
}).call(this);
window.gettext = function(s){return s;};
window.ngettext = function(singular, plural, num){ return num == 1 ? singular : plural }
function interpolate(fmt, obj, named) {
if (named) {
return fmt.replace(/%\(\w+\)s/g, function(match){return String(obj[match.slice(2,-2)])});
} else {
return fmt.replace(/%s/g, function(match){return String(obj.shift())});
}
}
// Generated by CoffeeScript 1.6.1
(function() {
var getTime;
getTime = function() {
return new Date().getTime();
};
this.MathJaxDelayRenderer = (function() {
var bufferId, numBuffers;
MathJaxDelayRenderer.prototype.maxDelay = 3000;
MathJaxDelayRenderer.prototype.mathjaxRunning = false;
MathJaxDelayRenderer.prototype.elapsedTime = 0;
MathJaxDelayRenderer.prototype.mathjaxDelay = 0;
MathJaxDelayRenderer.prototype.mathjaxTimeout = void 0;
bufferId = "mathjax_delay_buffer";
numBuffers = 0;
function MathJaxDelayRenderer(params) {
params = params || {};
this.maxDelay = params["maxDelay"] || this.maxDelay;
this.bufferId = params["bufferId"] || (bufferId + numBuffers);
numBuffers += 1;
this.$buffer = $("<div>").attr("id", this.bufferId).css("display", "none").appendTo($("body"));
}
MathJaxDelayRenderer.prototype.render = function(params) {
var delay, elem, preprocessor, previewSetter, renderer, text,
_this = this;
elem = params["element"];
previewSetter = params["previewSetter"];
text = params["text"];
if (text == null) {
text = $(elem).html();
}
preprocessor = params["preprocessor"];
if (params["delay"] === false) {
if (preprocessor != null) {
text = preprocessor(text);
}
$(elem).html(text);
return MathJax.Hub.Queue(["Typeset", MathJax.Hub, $(elem).attr("id")]);
} else {
if (this.mathjaxTimeout) {
window.clearTimeout(this.mathjaxTimeout);
this.mathjaxTimeout = void 0;
}
delay = Math.min(this.elapsedTime + this.mathjaxDelay, this.maxDelay);
renderer = function() {
var curTime, prevTime;
if (_this.mathjaxRunning) {
return;
}
prevTime = getTime();
if (preprocessor != null) {
text = preprocessor(text);
}
_this.$buffer.html(text);
curTime = getTime();
_this.elapsedTime = curTime - prevTime;
if (MathJax) {
prevTime = getTime();
_this.mathjaxRunning = true;
return MathJax.Hub.Queue(["Typeset", MathJax.Hub, _this.$buffer.attr("id")], function() {
_this.mathjaxRunning = false;
curTime = getTime();
_this.mathjaxDelay = curTime - prevTime;
if (previewSetter) {
return previewSetter($(_this.$buffer).html());
} else {
return $(elem).html($(_this.$buffer).html());
}
});
} else {
return _this.mathjaxDelay = 0;
}
};
return this.mathjaxTimeout = window.setTimeout(renderer, delay);
}
};
return MathJaxDelayRenderer;
})();
}).call(this);
/*!
* Cross-Browser Split 1.1.1
* Copyright 2007-2012 Steven Levithan <stevenlevithan.com>
* Available under the MIT License
* ECMAScript compliant, uniform cross-browser split method
*/
/**
* Splits a string into an array of strings using a regex or string separator. Matches of the
* separator are not included in the result array. However, if `separator` is a regex that contains
* capturing groups, backreferences are spliced into the result each time `separator` is matched.
* Fixes browser bugs compared to the native `String.prototype.split` and can be used reliably
* cross-browser.
* @param {String} str String to split.
* @param {RegExp|String} separator Regex or string to use for separating the string.
* @param {Number} [limit] Maximum number of items to include in the result array.
* @returns {Array} Array of substrings.
* @example
*
* // Basic use
* split('a b c d', ' ');
* // -> ['a', 'b', 'c', 'd']
*
* // With limit
* split('a b c d', ' ', 2);
* // -> ['a', 'b']
*
* // Backreferences in result array
* split('..word1 word2..', /([a-z]+)(\d+)/i);
* // -> ['..', 'word', '1', ' ', 'word', '2', '..']
*/
var _split; // instead of split for a less common name; avoid conflict
// Avoid running twice; that would break the `nativeSplit` reference
_split = _split || function (undef) {
var nativeSplit = String.prototype.split,
compliantExecNpcg = /()??/.exec("")[1] === undef, // NPCG: nonparticipating capturing group
self;
self = function (str, separator, limit) {
// If `separator` is not a regex, use `nativeSplit`
if (Object.prototype.toString.call(separator) !== "[object RegExp]") {
return nativeSplit.call(str, separator, limit);
}
var output = [],
flags = (separator.ignoreCase ? "i" : "") +
(separator.multiline ? "m" : "") +
(separator.extended ? "x" : "") + // Proposed for ES6
(separator.sticky ? "y" : ""), // Firefox 3+
lastLastIndex = 0,
// Make `global` and avoid `lastIndex` issues by working with a copy
separator = new RegExp(separator.source, flags + "g"),
separator2, match, lastIndex, lastLength;
str += ""; // Type-convert
if (!compliantExecNpcg) {
// Doesn't need flags gy, but they don't hurt
separator2 = new RegExp("^" + separator.source + "$(?!\\s)", flags);
}
/* Values for `limit`, per the spec:
* If undefined: 4294967295 // Math.pow(2, 32) - 1
* If 0, Infinity, or NaN: 0
* If positive number: limit = Math.floor(limit); if (limit > 4294967295) limit -= 4294967296;
* If negative number: 4294967296 - Math.floor(Math.abs(limit))
* If other: Type-convert, then use the above rules
*/
limit = limit === undef ?
-1 >>> 0 : // Math.pow(2, 32) - 1
limit >>> 0; // ToUint32(limit)
while (match = separator.exec(str)) {
// `separator.lastIndex` is not reliable cross-browser
lastIndex = match.index + match[0].length;
if (lastIndex > lastLastIndex) {
output.push(str.slice(lastLastIndex, match.index));
// Fix browsers whose `exec` methods don't consistently return `undefined` for
// nonparticipating capturing groups
if (!compliantExecNpcg && match.length > 1) {
match[0].replace(separator2, function () {
for (var i = 1; i < arguments.length - 2; i++) {
if (arguments[i] === undef) {
match[i] = undef;
}
}
});
}
if (match.length > 1 && match.index < str.length) {
Array.prototype.push.apply(output, match.slice(1));
}
lastLength = match[0].length;
lastLastIndex = lastIndex;
if (output.length >= limit) {
break;
}
}
if (separator.lastIndex === match.index) {
separator.lastIndex++; // Avoid an infinite loop
}
}
if (lastLastIndex === str.length) {
if (lastLength || !separator.test("")) {
output.push("");
}
} else {
output.push(str.slice(lastLastIndex));
}
return output.length > limit ? output.slice(0, limit) : output;
};
// For convenience
String.prototype.split = function (separator, limit) {
return self(this, separator, limit);
};
return self;
}();
<%! from django.utils.translation import ugettext as _ %>
<div class="discussion-course">
% if enable_new_post_btn and has_permission_to_create_thread:
<div class="discussion-course-new right">
<a href="#" class="new-post-btn" role="button"><span class="icon icon-edit new-post-icon"></span>${_("New Post")}</a>
</div>
<div style="display: block; clear: both; height: 1px;"></div>
% endif
<section class="discussion container" id="discussion-container"
data-roles="${roles}"
data-course-id="${course_id}"
data-user-info="${user_info}"
data-threads="${threads}"
data-thread-pages="${thread_pages}"
data-content-info="${annotated_content_info}"
data-sort-preference="${sort_preference}"
data-flag-moderator="${flag_moderator}"
data-user-cohort-id="${user_cohort}"
data-course-settings="${course_settings}">
<div class="discussion-body">
<div class="forum-nav"></div>
<div class="discussion-column">
<article class="new-post-article" style="display: none"></article>
<div class="forum-content"></div>
</div>
</div>
</section>
<%include file="_underscore_templates.html" />
<%include file="_thread_list_template.html" />
</div>
<%! from django.utils.translation import ugettext as _ %>
<div class="discussion-module">
<p>
<span class="discussion-preview">
<span class="icon icon-comment"/>
${_("To view live course discussions, click Preview or View Live in Unit Settings.")}
</span>
</p>
</div>
......@@ -37,22 +37,16 @@
<option value="flagged">${_("Flagged")}</option>
%endif
</select>
</label>\
%if is_course_cohorted and is_moderator:
## Lack of indentation is intentional to avoid whitespace between this and siblings
<label class="forum-nav-filter-cohort">
</label>${"<% if (isCohorted && isPrivilegedUser) { %>"}<label class="forum-nav-filter-cohort">
## Translators: This labels a cohort menu in forum navigation
<span class="sr">${_("Cohort:")}</span>
<select class="forum-nav-filter-cohort-control">
<option value="all">${_("in all cohorts")}</option>
<option value="">${_("in all cohorts")}</option>
%for c in cohorts:
<option value="${c['id']}">${c['name']}</option>
%endfor
</select>
</label>\
%endif
## Lack of indentation is intentional to avoid whitespace between this and siblings
<label class="forum-nav-sort">
</label>${"<% } %>"}<label class="forum-nav-sort">
## Translators: This labels a sort menu in forum navigation
<span class="sr">${_("Sort:")}</span>
<select class="forum-nav-sort-control">
......
<%! from django.utils.translation import ugettext as _ %>
<div id="discussion-edit" class="wrapper-comp-settings is-active editor-with-buttons" id="settings-tab">
<ul class="list-input settings-list">
<li class="field comp-setting-entry is-set">
<div class="wrapper-comp-setting">
<label class="label setting-label" for="display-name">Display Name</label>
<input class="input setting-input" id="display-name" value="${display_name}" type="text">
</div>
<span class="tip setting-help">Display Name</span>
</li>
<li class="field comp-setting-entry is-set">
<div class="wrapper-comp-setting">
<label class="label setting-label" for="discussion-category">Discussion Category</label>
<input class="input setting-input" id="discussion-category" value="${discussion_category}" type="text">
</div>
<span class="tip setting-help">Discussion Category</span>
</li>
<li class="field comp-setting-entry is-set">
<div class="wrapper-comp-setting">
<label class="label setting-label" for="discussion-target">Discussion Target</label>
<input class="input setting-input" id="discussion-target" value="${discussion_target}" type="text">
</div>
<span class="tip setting-help">Discussion Target</span>
</li>
</ul>
<div class="xblock-actions">
<span class="xblock-editor-error-message"></span>
<ul>
<li class="action-item">
<a href="#" class="button action-primary save-button">${_('Save')}</a>
</li>
<li class="action-item">
<a href="#" class="button cancel-button">${_('Cancel')}</a>
</li>
</ul>
</div>
</div>
......@@ -12,13 +12,36 @@
<%block name="headextra">
<%static:css group='style-course-vendor'/>
<%static:css group='style-course'/>
<%include file="_js_head_dependencies.html" />
</%block>
<%block name="js_extra">
<%include file="/discussion/_js_body_dependencies.html" />
<%include file="_js_body_dependencies.html" />
<%static:js group='discussion'/>
</%block>
<%include file="_discussion_course_navigation.html" args="active_page='discussion'" />
<%include file="/discussion/_discussion_course.html" />
<section class="discussion container" id="discussion-container"
data-roles="${roles}"
data-course-id="${course_id | h}"
data-user-info="${user_info}"
data-threads="${threads}"
data-thread-pages="${thread_pages}"
data-content-info="${annotated_content_info}"
data-sort-preference="${sort_preference}"
data-flag-moderator="${flag_moderator}"
data-user-cohort-id="${user_cohort}"
data-course-settings="${course_settings}">
<div class="discussion-body">
<div class="forum-nav"></div>
<div class="discussion-column">
<article class="new-post-article" style="display: none"></article>
<div class="forum-content"></div>
</div>
</div>
</section>
<%include file="_underscore_templates.html" />
<%include file="_thread_list_template.html" />
......@@ -9,10 +9,12 @@
<%block name="headextra">
<%static:css group='style-course-vendor'/>
<%static:css group='style-course'/>
<%include file="_js_head_dependencies.html" />
</%block>
<%block name="js_extra">
<%include file="/discussion/_js_body_dependencies.html" />
<%include file="_js_body_dependencies.html" />
<%static:js group='discussion'/>
</%block>
......@@ -24,14 +26,14 @@
<nav>
<article class="sidebar-module discussion-sidebar">
<%include file="/discussion/_user_profile.html" />
<%include file="_user_profile.html" />
</article>
</nav>
</section>
<section class="course-content container discussion-user-threads" data-course-id="${course.id.to_deprecated_string() | h}" data-threads="${threads}" data-user-info="${user_info}" data-page="${page}" data-num-pages="${num_pages}"/>
<section class="course-content container discussion-user-threads" data-course-id="${course.id | h}" data-threads="${threads}" data-user-info="${user_info}" data-page="${page}" data-num-pages="${num_pages}"/>
</div>
</section>
<%include file="/discussion/_underscore_templates.html" />
<%include file="_underscore_templates.html" />
<div class="discussion-post">
<header>
<div class="group-visibility-label">
<% if (obj.group_name) { %>
<%-
interpolate(
gettext('This post is visible only to %(group_name)s.'),
{group_name: obj.group_name},
true
)
%>
<% } else { %>
<%- gettext('This post is visible to everyone.') %>
<% } %>
</div>
<div class="post-header-content">
<h1><%- title %></h1>
<p class="posted-details">
<%
var timeAgoHtml = interpolate(
'<span class="timeago" title="%(created_at)s">%(created_at)s</span>',
{created_at: created_at},
true
);
%>
<%=
interpolate(
// Translators: post_type describes the kind of post this is (e.g. "question" or "discussion");
// time_ago is how much time has passed since the post was created (e.g. "4 hours ago")
_.escape(gettext('%(post_type)s posted %(time_ago)s by %(author)s')),
{post_type: thread_type, time_ago: timeAgoHtml, author: author_display},
true
)
%>
</p>
<div class="post-labels">
<span class="post-label-pinned"><i class="icon icon-pushpin"></i><%- gettext("Pinned") %></span>
<span class="post-label-reported"><i class="icon icon-flag"></i><%- gettext("Reported") %></span>
<span class="post-label-closed"><i class="icon icon-lock"></i><%- gettext("Closed") %></span>
</div>
</div>
<div class="post-header-actions post-extended-content">
<%=
_.template(
$('#forum-actions').html(),
{
contentId: cid,
contentType: 'post',
primaryActions: ['vote', 'follow'],
secondaryActions: ['pin', 'edit', 'delete', 'report', 'close']
}
)
%>
</div>
</header>
<div class="post-body"><%- body %></div>
<% if (mode == "tab" && obj.courseware_url) { %>
<%
var courseware_title_linked = interpolate(
'<a href="%(courseware_url)s">%(courseware_title)s</a>',
{courseware_url: courseware_url, courseware_title: _.escape(courseware_title)},
true
);
%>
<div class="post-context">
<%=
interpolate(
_.escape(gettext('(this post is about %(courseware_title_linked)s)')),
{courseware_title_linked: courseware_title_linked},
true
)
%>
</div>
<% } %>
</div>
......@@ -262,13 +262,28 @@ class DiscussionTabSingleThreadPage(CoursePage):
class InlineDiscussionPage(PageObject):
url = None
def __init__(self, browser, discussion_id):
def __init__(self, browser, discussion_id=None):
super(InlineDiscussionPage, self).__init__(browser)
self._discussion_selector = (
"body.courseware .discussion-module[data-discussion-id='{discussion_id}'] ".format(
discussion_id=discussion_id
if not discussion_id is None:
self._discussion_selector = (
"body.courseware .discussion-module[data-discussion-id='{discussion_id}'] ".format(
discussion_id=discussion_id
)
)
)
self._discussion_id = discussion_id
else:
self._discussion_selector = "body.courseware .discussion-module[data-discussion-id] "
self._discussion_id = None
def get_discussion_id(self):
"""
Gets configured discussion_id for the page.
Uses discussion_id passed to __init__ if it was not None, otherwise picks it from XBlock element data attribute
"""
if self._discussion_id is not None:
return self._discussion_id
return self.q(css=self._discussion_selector).first.attrs('data-discussion-id')[0]
def _find_within(self, selector):
"""
......
......@@ -3,16 +3,14 @@ import os
from django.conf import settings
from mako.template import Template
from discussion_app.views import get_template_dir as discussion_get_template_dir
def include_mustache_templates():
mustache_dir = discussion_get_template_dir() + '/discussion/mustache'
mustache_dir = settings.COMMON_ROOT / 'templates' / 'discussion' / 'mustache'
def is_valid_file_name(file_name):
return file_name.endswith('.mustache')
def read_file(file_name):
return open(mustache_dir + '/' + file_name, "r").read().decode('utf-8')
return open(mustache_dir / file_name, "r").read().decode('utf-8')
def template_id_from_file_name(file_name):
return file_name.rpartition('.')[0]
def process_mako(template_content):
......
......@@ -37,11 +37,6 @@ from xmodule.modulestore.modulestore_settings import update_module_store_setting
from lms.lib.xblock.mixin import LmsBlockMixin
from discussion_app.views import (
get_js_urls as discussion_get_js_urls,
get_css_urls as discussion_get_css_urls
)
################################### FEATURES ###################################
# The display name of the platform to be used in templates/emails/etc.
PLATFORM_NAME = "Your Platform Name Here"
......@@ -998,9 +993,12 @@ main_vendor_js = [
'js/vendor/ova/catch/js/catch.js',
'js/vendor/ova/catch/js/handlebars-1.1.2.js',
'js/vendor/URI.min.js',
'js/vendor/underscore-min.js',
'js/vendor/backbone-min.js',
]
discussion_js = discussion_get_js_urls()
dashboard_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'js/dashboard/**/*.js'))
discussion_js = sorted(rooted_glob(COMMON_ROOT / 'static', 'coffee/src/discussion/**/*.js'))
staff_grading_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/staff_grading/**/*.js'))
open_ended_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/open_ended/**/*.js'))
notes_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/notes/**/*.js'))
......@@ -1047,10 +1045,6 @@ PIPELINE_CSS = {
],
'output_filename': 'css/lms-style-app-extend2.css',
},
'style-discussion-app': {
'source_filenames': discussion_get_css_urls(),
'output_filename': 'css/lms-style-discussion-app.css',
},
'style-course-vendor': {
'source_filenames': [
'js/vendor/CodeMirror/codemirror.css',
......@@ -1376,7 +1370,6 @@ INSTALLED_APPS = (
'django_comment_client',
'django_comment_common',
'notes',
'discussion_app',
# Splash screen
'splash',
......
......@@ -153,6 +153,40 @@ class LmsUser(object):
return self.get_real_user(self.anonymous_student_id)
class LmsCourse(object):
"""
A runtime mixin that provides the course object.
This must be mixed in to a runtime that already accepts and stores
a course_id.
"""
@property
def course(self):
"""
Returns course object
"""
# TODO using 'modulestore().get_course(self._course_id)' doesn't work. return None
from courseware.courses import get_course
return get_course(self.course_id) # pylint: disable=no-member
class LmsUser(object):
"""
A runtime mixin that provides the user object.
This must be mixed in to a runtime that already accepts and stores
a anonymous_student_id and has get_real_user method.
"""
@property
def user(self):
"""
Returns user object
"""
return self.get_real_user(self.anonymous_student_id) # pylint: disable=no-member
class LmsPartitionService(PartitionService):
"""
Another runtime mixin that provides access to the student partitions defined on the
......
// forums - main app styling
// ====================
body.discussion, .discussion-course {
body.discussion, .discussion-course, div.discussion-body {
// new post creation
.new-post-form-errors {
......
// discussion - elements - labels
// ====================
body.discussion, .discussion-module {
body.discussion, .discussion-module, .discussion-body {
.post-label-pinned {
@include forum-post-label($forum-color-pinned);
}
......
<%! from django.utils.translation import ugettext as _ %>
<div class="blank-state">
% if performed_search:
${_("Sorry! We can't find anything matching your search. Please try another search.")}
% else:
${_("There are no posts here yet. Be the first one to post!")}
% endif
</div>
<%! from django.template.defaultfilters import escapejs %>
<script type="text/javascript">
var $$course_id = "${course_id | escapejs}";
if (typeof $$annotated_content_info === undefined) {
var $$annotated_content_info = {};
}
$$annotated_content_info = $.extend($$annotated_content_info, JSON.parse("${annotated_content_info | escapejs}"));
if (typeof $$discussion_data === undefined) {
var $$discussion_data = {};
}
$$discussion_data = $.extend($$discussion_data, JSON.parse("${discussion_data | escapejs}"));
</script>
<%! from django.utils.translation import ugettext as _ %>
<%! import django_comment_client.helpers as helpers %>
% if recent_active_threads:
<article class="discussion-sidebar-following sidebar-module">
<header>
<h4>${_("Following")}</h4>
<!--<a href="#" class="sidebar-view-all">view all &rsaquo;</a>-->
</header>
<ol class="discussion-sidebar-following-list">
% for thread in recent_active_threads:
<li><a href="${helpers.permalink(thread) | h}"><span class="sidebar-following-name">${thread['title'] | h}</span> <span class="sidebar-vote-count">${thread['votes']['point'] | h}</span></a></li>
% endfor
<ol>
</article>
% endif
<%! from django.utils.translation import ugettext as _ %>
<%! from urllib import urlencode %>
<%
def merge(dic1, dic2):
return dict(dic1.items() + dic2.items())
def base_url_for_search():
return base_url + '?' + urlencode(merge(query_params, {'page': 1}))
%>
<form action="${base_url_for_search()}" method="get" class="discussion-search-form">
<input class="search-input" type="text" value="${text | h}" id="keywords" autocomplete="off"/>
<div class="discussion-link discussion-search-link" href="javascript:void(0)">${_("Search posts")}</div>
</form>
......@@ -58,7 +58,6 @@
<%static:css group='style-app'/>
<%static:css group='style-app-extend1'/>
<%static:css group='style-app-extend2'/>
<%static:css group='style-discussion-app'/>
<%static:js group='main_vendor'/>
......
......@@ -13,7 +13,6 @@
{% compressed_css 'style-app-extend2' %}
{% compressed_css 'style-course-vendor' %}
{% compressed_css 'style-course' %}
{% compressed_css 'style-discussion-app' %}
{% block main_vendor_js %}
{% compressed_js 'main_vendor' %}
......
......@@ -7,3 +7,6 @@
-e common/lib/sandbox-packages
-e common/lib/symmath
-e common/lib/xmodule
# XBlocks
-e common/lib/xblock/discussion
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment