diff --git a/cms/static/coffee/fixtures/tabs-edit.html b/cms/static/coffee/fixtures/tabs-edit.html new file mode 100644 index 000000000000..c83a1456226c --- /dev/null +++ b/cms/static/coffee/fixtures/tabs-edit.html @@ -0,0 +1,33 @@ +
+
+
+ +
+
+ +
+
+ Transcripts +
+
+ Subtitles +
+
+
+ +
+
+
+ +
+
+ diff --git a/cms/static/coffee/spec/tabs/edit.coffee b/cms/static/coffee/spec/tabs/edit.coffee new file mode 100644 index 000000000000..734e398c743d --- /dev/null +++ b/cms/static/coffee/spec/tabs/edit.coffee @@ -0,0 +1,95 @@ +describe "TabsEditingDescriptor", -> + beforeEach -> + @isInactiveClass = "is-inactive" + @isCurrent = "current" + loadFixtures 'tabs-edit.html' + @descriptor = new TabsEditingDescriptor($('.base_wrapper')) + @html_id = 'test_id' + @tab_0_switch = jasmine.createSpy('tab_0_switch'); + @tab_0_modelUpdate = jasmine.createSpy('tab_0_modelUpdate'); + @tab_1_switch = jasmine.createSpy('tab_1_switch'); + @tab_1_modelUpdate = jasmine.createSpy('tab_1_modelUpdate'); + TabsEditingDescriptor.Model.addModelUpdate(@html_id, 'Tab 0 Editor', @tab_0_modelUpdate) + TabsEditingDescriptor.Model.addOnSwitch(@html_id, 'Tab 0 Editor', @tab_0_switch) + TabsEditingDescriptor.Model.addModelUpdate(@html_id, 'Tab 1 Transcripts', @tab_1_modelUpdate) + TabsEditingDescriptor.Model.addOnSwitch(@html_id, 'Tab 1 Transcripts', @tab_1_switch) + + spyOn($.fn, 'hide').andCallThrough() + spyOn($.fn, 'show').andCallThrough() + spyOn(TabsEditingDescriptor.Model, 'initialize') + spyOn(TabsEditingDescriptor.Model, 'updateValue') + + afterEach -> + TabsEditingDescriptor.Model.modules= {} + + describe "constructor", -> + it "first tab should be visible", -> + expect(@descriptor.$tabs.first()).toHaveClass(@isCurrent) + expect(@descriptor.$content.first()).not.toHaveClass(@isInactiveClass) + + describe "onSwitchEditor", -> + it "switching tabs changes styles", -> + @descriptor.$tabs.eq(1).trigger("click") + expect(@descriptor.$tabs.eq(0)).not.toHaveClass(@isCurrent) + expect(@descriptor.$content.eq(0)).toHaveClass(@isInactiveClass) + expect(@descriptor.$tabs.eq(1)).toHaveClass(@isCurrent) + expect(@descriptor.$content.eq(1)).not.toHaveClass(@isInactiveClass) + expect(@tab_1_switch).toHaveBeenCalled() + + it "if click on current tab, nothing should happen", -> + spyOn($.fn, 'trigger').andCallThrough() + currentTab = @descriptor.$tabs.filter('.' + @isCurrent) + @descriptor.$tabs.eq(0).trigger("click") + expect(@descriptor.$tabs.filter('.' + @isCurrent)).toEqual(currentTab) + expect($.fn.trigger.calls.length).toEqual(1) + + it "onSwitch function call", -> + @descriptor.$tabs.eq(1).trigger("click") + expect(TabsEditingDescriptor.Model.updateValue).toHaveBeenCalled() + expect(@tab_1_switch).toHaveBeenCalled() + + describe "save", -> + it "function for current tab should be called", -> + @descriptor.$tabs.eq(1).trigger("click") + data = @descriptor.save().data + expect(@tab_1_modelUpdate).toHaveBeenCalled() + + it "detach click event", -> + spyOn($.fn, "off") + @descriptor.save() + expect($.fn.off).toHaveBeenCalledWith( + 'click', + '.editor-tabs .tab', + @descriptor.onSwitchEditor + ) + + describe "editor/settings header", -> + it "is hidden", -> + expect(@descriptor.element.find(".component-edit-header").css('display')).toEqual('none') + +describe "TabsEditingDescriptor special save cases", -> + beforeEach -> + @isInactiveClass = "is-inactive" + @isCurrent = "current" + loadFixtures 'tabs-edit.html' + @descriptor = new window.TabsEditingDescriptor($('.base_wrapper')) + @html_id = 'test_id' + + describe "save", -> + it "case: no init", -> + data = @descriptor.save().data + expect(data).toEqual(null) + + it "case: no function in model update", -> + TabsEditingDescriptor.Model.initialize(@html_id) + data = @descriptor.save().data + expect(data).toEqual(null) + + it "case: no function in model update, but value presented", -> + @tab_0_modelUpdate = jasmine.createSpy('tab_0_modelUpdate').andReturn(1) + TabsEditingDescriptor.Model.addModelUpdate(@html_id, 'Tab 0 Editor', @tab_0_modelUpdate) + @descriptor.$tabs.eq(1).trigger("click") + expect(@tab_0_modelUpdate).toHaveBeenCalled() + data = @descriptor.save().data + expect(data).toEqual(1) + diff --git a/cms/templates/widgets/tabs-aggregator.html b/cms/templates/widgets/tabs-aggregator.html new file mode 100644 index 000000000000..d5953005b530 --- /dev/null +++ b/cms/templates/widgets/tabs-aggregator.html @@ -0,0 +1,21 @@ +<%! from django.utils.translation import ugettext as _ %> +
+
+
+ + +
+
+ % for tab in tabs: +
+ <%include file="${tab['template']}" args="tabName=tab['name']"/> +
+ % endfor +
+
+
+ diff --git a/cms/templates/widgets/tabs/metadata-edit-tab.html b/cms/templates/widgets/tabs/metadata-edit-tab.html new file mode 100644 index 000000000000..4fc8314afd52 --- /dev/null +++ b/cms/templates/widgets/tabs/metadata-edit-tab.html @@ -0,0 +1,24 @@ +<%namespace name='static' file='../../static_content.html'/> +<% + import json +%> + +## js templates + + + + + + + + +
+ diff --git a/cms/templates/widgets/videoalpha/codemirror-edit.html b/cms/templates/widgets/videoalpha/codemirror-edit.html new file mode 100644 index 000000000000..10c28d829c56 --- /dev/null +++ b/cms/templates/widgets/videoalpha/codemirror-edit.html @@ -0,0 +1,33 @@ +<%! from django.utils.translation import ugettext as _ %> +<%page args="tabName"/> +
+ +
+ + + diff --git a/cms/templates/widgets/videoalpha/subtitles.html b/cms/templates/widgets/videoalpha/subtitles.html new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/common/lib/xmodule/test_files/test_tabseditingdescriptor.css b/common/lib/xmodule/test_files/test_tabseditingdescriptor.css new file mode 100644 index 000000000000..eba23dedfdc7 --- /dev/null +++ b/common/lib/xmodule/test_files/test_tabseditingdescriptor.css @@ -0,0 +1,4 @@ +.supertestclass{ + color: red; +} + diff --git a/common/lib/xmodule/test_files/test_tabseditingdescriptor.scss b/common/lib/xmodule/test_files/test_tabseditingdescriptor.scss new file mode 100644 index 000000000000..eba23dedfdc7 --- /dev/null +++ b/common/lib/xmodule/test_files/test_tabseditingdescriptor.scss @@ -0,0 +1,4 @@ +.supertestclass{ + color: red; +} + diff --git a/common/lib/xmodule/xmodule/css/tabs/codemirror.scss b/common/lib/xmodule/xmodule/css/tabs/codemirror.scss new file mode 100644 index 000000000000..0b10a1a09dc5 --- /dev/null +++ b/common/lib/xmodule/xmodule/css/tabs/codemirror.scss @@ -0,0 +1,19 @@ +.editor{ + @include clearfix(); + + .CodeMirror { + @include box-sizing(border-box); + width: 100%; + position: relative; + height: 379px; + border: 1px solid #3c3c3c; + border-top: 1px solid #8891a1; + background: $white; + color: #3c3c3c; + } + + .CodeMirror-scroll { + height: 100%; + } +} + diff --git a/common/lib/xmodule/xmodule/css/tabs/tabs.scss b/common/lib/xmodule/xmodule/css/tabs/tabs.scss new file mode 100644 index 000000000000..3991bbad2327 --- /dev/null +++ b/common/lib/xmodule/xmodule/css/tabs/tabs.scss @@ -0,0 +1,137 @@ +// styles duped from _unit.scss - Edit Header (Component Name, Mode-Editor, Mode-Settings) + + +.tabs-wrapper{ + padding-top: 0; + position: relative; + + .wrapper-comp-settings { + // set visibility to metadata editor + display: block; + } +} + +.editor-single-tab-name { + display: none; +} + + +.editor-with-tabs { + @include clearfix(); + position: relative; + + + .edit-header { + @include box-sizing(border-box); + padding: 18px 0 18px $baseline; + top: 0 !important; // ugly override for second level tab override + right: 0; + background-color: $blue; + border-bottom: 1px solid $blue-d2; + color: $white; + + //Component Name + .component-name { + @extend .t-copy-sub1; + position: relative; + top: 0; + left: 0; + width: 50%; + color: $white; + font-weight: 600; + + + + em { + display: inline-block; + margin-right: ($baseline/4); + font-weight: 400; + color: $white; + } + } + + //Nav-Edit Modes + .editor-tabs { + list-style: none; + right: 0; + top: ($baseline/4); + position: absolute; + padding: 12px ($baseline*0.75); + + .inner_tab_wrap { + display: inline-block; + margin-left: 8px; + + a.tab { + @include font-size(14); + @include linear-gradient(top, rgba(255, 255, 255, .3), rgba(255, 255, 255, 0)); + border: 1px solid $blue-d1; + border-radius: 3px; + padding: ($baseline/4) ($baseline); + background-color: $blue; + font-weight: bold; + color: $white; + + &.current { + @include linear-gradient($blue, $blue); + color: $blue-d1; + box-shadow: inset 0 1px 2px 1px $shadow-l1; + background-color: $blue-d4; + cursor: default; + } + + &:hover { + box-shadow: inset 0 1px 2px 1px $shadow; + background-image: linear-gradient(#009FE6, #009FE6) !important; + } + } + } + } + } + + .is-inactive { + display: none; + } + + .comp-subtitles-entry { + text-align: center; + + .file-upload { + display: none; + } + + .comp-subtitles-import-list { + > li { + display: block; + margin: $baseline/2 0px $baseline/2 0; + } + + .blue-button { + font-size: 1em; + display: block; + width: 70%; + margin: 0 auto; + text-align: center; + } + } + } + + +} + +.component-tab { + background: $white; + position: relative; + border-top: 1px solid #8891a1; + + &#advanced { + padding: 0; + border: none; + } + + .blue-button { + @include blue-button; + } +} + + diff --git a/common/lib/xmodule/xmodule/css/videoalpha/common_tabs_edit.scss b/common/lib/xmodule/xmodule/css/videoalpha/common_tabs_edit.scss new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/common/lib/xmodule/xmodule/editing_module.py b/common/lib/xmodule/xmodule/editing_module.py index df4ebc564613..eed8fb310f5e 100644 --- a/common/lib/xmodule/xmodule/editing_module.py +++ b/common/lib/xmodule/xmodule/editing_module.py @@ -1,3 +1,5 @@ +"""Descriptors for XBlocks/Xmodules, that provide editing of atrributes""" + from pkg_resources import resource_string from xmodule.mako_module import MakoModuleDescriptor from xblock.core import Scope, String @@ -7,6 +9,7 @@ class EditingFields(object): + """Contains specific template information (the raw data body)""" data = String(scope=Scope.content, default='') @@ -29,6 +32,46 @@ def get_context(self): return _context +class TabsEditingDescriptor(EditingFields, MakoModuleDescriptor): + """ + Module that provides a raw editing view of its data and children. It does not + perform any validation on its definition---just passes it along to the browser. + + This class is intended to be used as a mixin. + + Engine (module_edit.js) wants for metadata editor + template to be always loaded, so don't forget to include + settings tab in your module descriptor. + """ + mako_template = "widgets/tabs-aggregator.html" + css = {'scss': [resource_string(__name__, 'css/tabs/tabs.scss')]} + js = {'coffee': [resource_string( + __name__, 'js/src/tabs/tabs-aggregator.coffee')]} + js_module_name = "TabsEditingDescriptor" + tabs = [] + + def get_context(self): + _context = super(TabsEditingDescriptor, self).get_context() + _context.update({ + 'tabs': self.tabs, + 'html_id': self.location.html_id(), # element_id + 'data': self.data, + }) + return _context + + @classmethod + def get_css(cls): + # load every tab's css + for tab in cls.tabs: + tab_styles = tab.get('css', {}) + for css_type, css_content in tab_styles.items(): + if css_type in cls.css: + cls.css[css_type].extend(css_content) + else: + cls.css[css_type] = css_content + return cls.css + + class XMLEditingDescriptor(EditingDescriptor): """ Module that provides a raw editing view of its data as XML. It does not perform diff --git a/common/lib/xmodule/xmodule/js/src/tabs/tabs-aggregator.coffee b/common/lib/xmodule/xmodule/js/src/tabs/tabs-aggregator.coffee new file mode 100644 index 000000000000..7c6e85e34b04 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/tabs/tabs-aggregator.coffee @@ -0,0 +1,125 @@ +class @TabsEditingDescriptor + @isInactiveClass : "is-inactive" + + constructor: (element) -> + @element = element; + ### + Not tested on syncing of multiple editors of same type in tabs + (Like many CodeMirrors). + ### + + # hide editor/settings bar + $('.component-edit-header').hide() + + @$tabs = $(".tab", @element) + @$content = $(".component-tab", @element) + + @element.find('.editor-tabs .tab').each (index, value) => + $(value).on('click', @onSwitchEditor) + + # If default visible tab is not setted or if were marked as current + # more than 1 tab just first tab will be shown + currentTab = @$tabs.filter('.current') + currentTab = @$tabs.first() if currentTab.length isnt 1 + @html_id = @$tabs.closest('.wrapper-comp-editor').data('html_id') + currentTab.trigger("click", [true, @html_id]) + + onSwitchEditor: (e, firstTime, html_id) => + e.preventDefault(); + + isInactiveClass = TabsEditingDescriptor.isInactiveClass + $currentTarget = $(e.currentTarget) + + if not $currentTarget.hasClass('current') or firstTime is true + previousTab = null + + @$tabs.each( (index, value) -> + if $(value).hasClass('current') + previousTab = $(value).html() + ) + + # init and save data from previous tab + TabsEditingDescriptor.Model.updateValue(@html_id, previousTab) + + # Save data from editor in previous tab to editor in current tab here. + # (to be implemented when there is a use case for this functionality) + + # call onswitch + onSwitchFunction = TabsEditingDescriptor.Model.modules[@html_id].tabSwitch[$currentTarget.text()] + onSwitchFunction() if $.isFunction(onSwitchFunction) + + @$tabs.removeClass('current') + $currentTarget.addClass('current') + + # Tabs are implemeted like anchors. Therefore we can use hash to find + # corresponding content + content_id = $currentTarget.attr('href') + + @$content + .addClass(isInactiveClass) + .filter(content_id) + .removeClass(isInactiveClass) + + save: -> + @element.off('click', '.editor-tabs .tab', @onSwitchEditor) + current_tab = @$tabs.filter('.current').html() + data: TabsEditingDescriptor.Model.getValue(@html_id, current_tab) + + @Model : + addModelUpdate : (id, tabName, modelUpdateFunction) -> + ### + Function that registers 'modelUpdate' functions of every tab. + These functions are used to update value, which will be returned + by calling save on component. + ### + @initialize(id) + @modules[id].modelUpdate[tabName] = modelUpdateFunction + + addOnSwitch : (id, tabName, onSwitchFunction) -> + ### + Function that registers functions invoked when switching + to particular tab. + ### + @initialize(id) + @modules[id].tabSwitch[tabName] = onSwitchFunction + + updateValue : (id, tabName) -> + ### + Function that invokes when switching tabs. + It ensures that data from previous tab is stored. + If new tab need this data, it should retrieve it from + stored value. + ### + @initialize(id) + modelUpdateFunction = @modules[id]['modelUpdate'][tabName] + @modules[id]['value'] = modelUpdateFunction() if $.isFunction(modelUpdateFunction) + + getValue : (id, tabName) -> + ### + Retrieves stored data on component save. + 1. When we switching tabs - previous tab data is always saved to @[id].value + 2. If current tab have registered 'modelUpdate' method, it should be invoked 1st. + (If we have edited in 1st tab, then switched to 2nd, 2nd tab should + care about getting data from @[id].value in onSwitch.) + ### + if not @modules[id] + return null + if $.isFunction(@modules[id]['modelUpdate'][tabName]) + return @modules[id]['modelUpdate'][tabName]() + else + if typeof @modules[id]['value'] is 'undefined' + return null + else + return @modules[id]['value'] + + # html_id's of descriptors will be stored in modules variable as + # containers for callbacks. + modules: {} + + initialize : (id) -> + ### + Initialize objects per id. Id is html_id of descriptor. + ### + @modules[id] = @modules[id] or {} + @modules[id].tabSwitch = @modules[id]['tabSwitch'] or {} + @modules[id].modelUpdate = @modules[id]['modelUpdate'] or {} diff --git a/common/lib/xmodule/xmodule/tests/test_editing_module.py b/common/lib/xmodule/xmodule/tests/test_editing_module.py new file mode 100644 index 000000000000..03e257940f9b --- /dev/null +++ b/common/lib/xmodule/xmodule/tests/test_editing_module.py @@ -0,0 +1,63 @@ +""" Tests for editing descriptors""" +import unittest +import os +import logging + +from mock import Mock +from pkg_resources import resource_string +from xmodule.editing_module import TabsEditingDescriptor + +from .import get_test_system + +log = logging.getLogger(__name__) + + +class TabsEditingDescriptorTestCase(unittest.TestCase): + """ Testing TabsEditingDescriptor""" + + def setUp(self): + super(TabsEditingDescriptorTestCase, self).setUp() + system = get_test_system() + system.render_template = Mock(return_value="
Test Template HTML
") + self.tabs = [ + { + 'name': "Test_css", + 'template': "tabs/codemirror-edit.html", + 'current': True, + 'css': { + 'scss': [resource_string(__name__, + '../../test_files/test_tabseditingdescriptor.scss')], + 'css': [resource_string(__name__, + '../../test_files/test_tabseditingdescriptor.css')] + } + }, + { + 'name': "Subtitles", + 'template': "videoalpha/subtitles.html", + }, + { + 'name': "Settings", + 'template': "tabs/video-metadata-edit-tab.html" + } + ] + + TabsEditingDescriptor.tabs = self.tabs + self.descriptor = TabsEditingDescriptor( + runtime=system, + model_data={}) + + def test_get_css(self): + """test get_css""" + css = self.descriptor.get_css() + test_files_dir = os.path.dirname(__file__).replace('xmodule/tests', 'test_files') + test_css_file = os.path.join(test_files_dir, 'test_tabseditingdescriptor.scss') + with open(test_css_file) as new_css: + added_css = new_css.read() + self.assertEqual(css['scss'].pop(), added_css) + self.assertEqual(css['css'].pop(), added_css) + + def test_get_context(self): + """"test get_context""" + rendered_context = self.descriptor.get_context() + self.assertListEqual(rendered_context['tabs'], self.tabs) + diff --git a/common/lib/xmodule/xmodule/tests/test_videoalpha.py b/common/lib/xmodule/xmodule/tests/test_videoalpha.py index f2258d1b55bd..659bfb2fa06d 100644 --- a/common/lib/xmodule/xmodule/tests/test_videoalpha.py +++ b/common/lib/xmodule/xmodule/tests/test_videoalpha.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """Test for Video Alpha Xmodule functional logic. -These tests data readed from xml, not from mongo. +These tests data readed from xml or from mongo. we have a ModuleStoreTestCase class defined in common/lib/xmodule/xmodule/modulestore/tests/django_utils.py. You can @@ -12,10 +12,12 @@ the course, section, subsection, unit, etc. """ +import unittest from xmodule.videoalpha_module import VideoAlphaDescriptor from . import LogicTest from lxml import etree - +from pkg_resources import resource_string +from .import get_test_system class VideoAlphaModuleTest(LogicTest): """Logic tests for VideoAlpha Xmodule.""" @@ -49,3 +51,31 @@ def test_get_timeframe_with_two_parameters(self): ) output = self.xmodule.get_timeframe(xmltree) self.assertEqual(output, (247, 47079)) + + +class VideoAlphaDescriptorTest(unittest.TestCase): + """Test for VideoAlphaDescriptor""" + + def setUp(self): + system = get_test_system() + self.descriptor = VideoAlphaDescriptor( + runtime=system, + model_data={}) + + def test_get_context(self): + """"test get_context""" + correct_tabs = [ + { + 'name': "XML", + 'template': "videoalpha/codemirror-edit.html", + 'css': {'scss': [resource_string(__name__, + '../css/tabs/codemirror.scss')]}, + 'current': True, + }, + { + 'name': "Settings", + 'template': "tabs/metadata-edit-tab.html" + } + ] + rendered_context = self.descriptor.get_context() + self.assertListEqual(rendered_context['tabs'], correct_tabs) diff --git a/common/lib/xmodule/xmodule/videoalpha_module.py b/common/lib/xmodule/xmodule/videoalpha_module.py index 4ca337dcb0db..084389411aae 100644 --- a/common/lib/xmodule/xmodule/videoalpha_module.py +++ b/common/lib/xmodule/xmodule/videoalpha_module.py @@ -20,7 +20,7 @@ from django.conf import settings from xmodule.x_module import XModule -from xmodule.raw_module import RawDescriptor +from xmodule.editing_module import TabsEditingDescriptor from xmodule.modulestore.mongo import MongoModuleStore from xmodule.modulestore.django import modulestore from xmodule.contentstore.content import StaticContent @@ -187,6 +187,23 @@ def get_html(self): }) -class VideoAlphaDescriptor(VideoAlphaFields, RawDescriptor): +class VideoAlphaDescriptor(VideoAlphaFields, TabsEditingDescriptor): """Descriptor for `VideoAlphaModule`.""" module_class = VideoAlphaModule + + tabs = [ + { + 'name': "XML", + 'template': "videoalpha/codemirror-edit.html", + 'css': {'scss': [resource_string(__name__, 'css/tabs/codemirror.scss')]}, + 'current': True, + }, + # { + # 'name': "Subtitles", + # 'template': "videoalpha/subtitles.html", + # }, + { + 'name': "Settings", + 'template': "tabs/metadata-edit-tab.html" + } + ]