From c7dc83c3784c8371db88970c5e0ca2695d6fff52 Mon Sep 17 00:00:00 2001 From: muhammad-ammar Date: Mon, 23 Jan 2017 12:54:41 +0500 Subject: [PATCH] Move modal show course outline with breadcrumb TNL-6060 --- .../contentstore/views/component.py | 3 +- cms/static/cms/js/spec/main.js | 3 +- .../views/modals/move_xblock_modal_spec.js | 75 ++++ .../js/spec/views/modals/move_xblock_spec.js | 36 -- cms/static/js/spec/views/move_xblock_spec.js | 345 ++++++++++++++++++ cms/static/js/views/modals/base_modal.js | 6 +- .../js/views/modals/move_xblock_modal.js | 69 +++- cms/static/js/views/move_xblock_breadcrumb.js | 52 +++ cms/static/js/views/move_xblock_list.js | 201 ++++++++++ cms/static/js/views/pages/container.js | 3 +- cms/static/sass/elements/_controls.scss | 14 + cms/static/sass/elements/_modal-window.scss | 14 + cms/static/sass/partials/_variables.scss | 2 + cms/static/sass/views/_container.scss | 111 ++++++ cms/templates/component.html | 7 +- cms/templates/container.html | 3 +- cms/templates/js/basic-modal.underscore | 15 +- .../js/move-xblock-breadcrumb.underscore | 17 + cms/templates/js/move-xblock-list.underscore | 46 +++ cms/templates/js/move-xblock-modal.underscore | 12 +- cms/templates/studio_xblock_wrapper.html | 7 +- 21 files changed, 985 insertions(+), 56 deletions(-) create mode 100644 cms/static/js/spec/views/modals/move_xblock_modal_spec.js delete mode 100644 cms/static/js/spec/views/modals/move_xblock_spec.js create mode 100644 cms/static/js/spec/views/move_xblock_spec.js create mode 100644 cms/static/js/views/move_xblock_breadcrumb.js create mode 100644 cms/static/js/views/move_xblock_list.js create mode 100644 cms/templates/js/move-xblock-breadcrumb.underscore create mode 100644 cms/templates/js/move-xblock-list.underscore diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index c33f195a01..378c4b2fd2 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -20,7 +20,7 @@ from xblock.exceptions import NoSuchHandlerError from xblock.plugin import PluginMissingError from xblock.runtime import Mixologist -from contentstore.utils import get_lms_link_for_item, get_xblock_aside_instance +from contentstore.utils import get_lms_link_for_item, reverse_course_url, get_xblock_aside_instance from contentstore.views.helpers import get_parent_xblock, is_unit, xblock_type_display_name from contentstore.views.item import create_xblock_info, add_container_page_publishing_info, StudioEditModuleRuntime @@ -165,6 +165,7 @@ def container_handler(request, usage_key_string): 'subsection': subsection, 'section': section, 'new_unit_category': 'vertical', + 'outline_url': '{url}?format=concise'.format(url=reverse_course_url('course_handler', course.id)), 'ancestor_xblocks': ancestor_xblocks, 'component_templates': component_templates, 'xblock_info': xblock_info, diff --git a/cms/static/cms/js/spec/main.js b/cms/static/cms/js/spec/main.js index a08508349c..393474e1f7 100644 --- a/cms/static/cms/js/spec/main.js +++ b/cms/static/cms/js/spec/main.js @@ -282,8 +282,9 @@ 'js/spec/views/pages/library_users_spec', 'js/spec/views/modals/base_modal_spec', 'js/spec/views/modals/edit_xblock_spec', - 'js/spec/views/modals/move_xblock_spec', + 'js/spec/views/modals/move_xblock_modal_spec', 'js/spec/views/modals/validation_error_modal_spec', + 'js/spec/views/move_xblock_spec', 'js/spec/views/settings/main_spec', 'js/spec/factories/xblock_validation_spec', 'js/certificates/spec/models/certificate_spec', diff --git a/cms/static/js/spec/views/modals/move_xblock_modal_spec.js b/cms/static/js/spec/views/modals/move_xblock_modal_spec.js new file mode 100644 index 0000000000..28bb1792cf --- /dev/null +++ b/cms/static/js/spec/views/modals/move_xblock_modal_spec.js @@ -0,0 +1,75 @@ +define(['jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', + 'common/js/spec_helpers/template_helpers', 'common/js/spec_helpers/view_helpers', + 'js/views/modals/move_xblock_modal', 'js/models/xblock_info'], + function($, _, AjaxHelpers, TemplateHelpers, ViewHelpers, MoveXBlockModal, XBlockInfo) { + 'use strict'; + describe('MoveXBlockModal', function() { + var modal, + showModal, + DISPLAY_NAME = 'HTML 101', + OUTLINE_URL = '/course/cid?format=concise', + ANCESTORS_URL = '/xblock/USAGE_ID?fields=ancestorInfo'; + + showModal = function() { + modal = new MoveXBlockModal({ + sourceXBlockInfo: new XBlockInfo({ + id: 'USAGE_ID', + display_name: DISPLAY_NAME, + category: 'html' + }), + XBlockURLRoot: '/xblock', + outlineURL: OUTLINE_URL, + XBlockAncestorInfoURL: ANCESTORS_URL + + }); + modal.show(); + }; + + beforeEach(function() { + setFixtures('
'); + TemplateHelpers.installTemplates([ + 'basic-modal', + 'modal-button', + 'move-xblock-modal' + ]); + }); + + afterEach(function() { + modal.hide(); + }); + + it('rendered as expected', function() { + showModal(); + expect( + modal.$el.find('.modal-header .title').contents().get(0).nodeValue.trim() + ).toEqual('Move: ' + DISPLAY_NAME); + expect( + modal.$el.find('.modal-sr-title').text().trim() + ).toEqual('Choose a location to move your component to'); + expect(modal.$el.find('.modal-actions .action-primary.action-move').text()).toEqual('Move'); + }); + + it('sends request to fetch course outline', function() { + var requests = AjaxHelpers.requests(this), + renderViewsSpy; + showModal(); + expect(modal.$el.find('.ui-loading.is-hidden')).not.toExist(); + renderViewsSpy = spyOn(modal, 'renderViews'); + expect(requests.length).toEqual(2); + AjaxHelpers.expectRequest(requests, 'GET', OUTLINE_URL); + AjaxHelpers.respondWithJson(requests, {}); + AjaxHelpers.expectRequest(requests, 'GET', ANCESTORS_URL); + AjaxHelpers.respondWithJson(requests, {}); + expect(renderViewsSpy).toHaveBeenCalled(); + expect(modal.$el.find('.ui-loading.is-hidden')).toExist(); + }); + + it('shows error notification when fetch course outline request fails', function() { + var requests = AjaxHelpers.requests(this), + notificationSpy = ViewHelpers.createNotificationSpy('Error'); + showModal(); + AjaxHelpers.respondWithError(requests); + ViewHelpers.verifyNotificationShowing(notificationSpy, "Studio's having trouble saving your work"); + }); + }); + }); diff --git a/cms/static/js/spec/views/modals/move_xblock_spec.js b/cms/static/js/spec/views/modals/move_xblock_spec.js deleted file mode 100644 index 61cb9505bb..0000000000 --- a/cms/static/js/spec/views/modals/move_xblock_spec.js +++ /dev/null @@ -1,36 +0,0 @@ -define(['jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', - 'common/js/spec_helpers/template_helpers', 'js/views/modals/move_xblock_modal', 'js/models/xblock_info'], - function($, _, AjaxHelpers, TemplateHelpers, MoveXBlockModal, XBlockInfo) { - 'use strict'; - describe('MoveXBlockModal', function() { - var modal, - showModal, - DISPLAY_NAME = 'HTML 101'; - - showModal = function() { - modal = new MoveXBlockModal({ - sourceXBlockInfo: new XBlockInfo({ - id: 'testCourse/branch/draft/block/verticalFFF', - display_name: DISPLAY_NAME, - category: 'html' - }), - XBlockUrlRoot: '/xblock' - }); - modal.show(); - }; - - beforeEach(function() { - TemplateHelpers.installTemplates([ - 'basic-modal', - 'modal-button', - 'move-xblock-modal' - ]); - showModal(); - }); - - it('rendered as expected', function() { - expect(modal.$el.find('.modal-header .title').text()).toEqual('Move: ' + DISPLAY_NAME); - expect(modal.$el.find('.modal-actions .action-primary.action-move').text()).toEqual('Move'); - }); - }); - }); diff --git a/cms/static/js/spec/views/move_xblock_spec.js b/cms/static/js/spec/views/move_xblock_spec.js new file mode 100644 index 0000000000..2df7c7ebc8 --- /dev/null +++ b/cms/static/js/spec/views/move_xblock_spec.js @@ -0,0 +1,345 @@ +define(['jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', + 'common/js/spec_helpers/template_helpers', 'js/views/move_xblock_list', + 'js/views/move_xblock_breadcrumb', 'js/models/xblock_info'], + function($, _, AjaxHelpers, TemplateHelpers, MoveXBlockListView, MoveXBlockBreadcrumbView, + XBlockInfoModel) { + 'use strict'; + describe('MoveXBlock', function() { + var renderViews, createXBlockInfo, createCourseOutline, moveXBlockBreadcrumbView, + moveXBlockListView, parentChildMap, categoryMap, createChildXBlockInfo, + verifyBreadcrumbViewInfo, verifyListViewInfo, getDisplayedInfo, clickForwardButton, + clickBreadcrumbButton, verifyXBlockInfo, nextCategory; + + parentChildMap = { + course: 'section', + section: 'subsection', + subsection: 'unit', + unit: 'component' + }; + + categoryMap = { + section: 'chapter', + subsection: 'sequential', + unit: 'vertical', + component: 'component' + }; + + beforeEach(function() { + setFixtures( + "
" + ); + TemplateHelpers.installTemplates([ + 'move-xblock-list', + 'move-xblock-breadcrumb' + ]); + }); + + afterEach(function() { + moveXBlockBreadcrumbView.remove(); + moveXBlockListView.remove(); + }); + + /** + * Create child XBlock info. + * + * @param {String} category XBlock category + * @param {Object} outlineOptions options according to which outline was created + * @param {Object} xblockIndex XBlock Index + * @returns + */ + createChildXBlockInfo = function(category, outlineOptions, xblockIndex) { + var childInfo = { + category: categoryMap[category], + display_name: category + '_display_name_' + xblockIndex, + id: category + '_ID' + }; + return createXBlockInfo(parentChildMap[category], outlineOptions, childInfo); + }; + + /** + * Create parent XBlock info. + * + * @param {String} category XBlock category + * @param {Object} outlineOptions options according to which outline was created + * @param {Object} outline ouline info being constructed + * @returns {Object} + */ + createXBlockInfo = function(category, outlineOptions, outline) { + var childInfo = { + category: categoryMap[category], + display_name: category, + children: [] + }, + xblocks; + + xblocks = outlineOptions[category]; + if (!xblocks) { + return outline; + } + + outline.child_info = childInfo; // eslint-disable-line no-param-reassign + _.each(_.range(xblocks), function(xblockIndex) { + childInfo.children.push( + createChildXBlockInfo(category, outlineOptions, xblockIndex) + ); + }); + return outline; + }; + + /** + * Create course outline. + * + * @param {Object} outlineOptions options according to which outline was created + * @returns {Object} + */ + createCourseOutline = function(outlineOptions) { + var courseOutline = { + category: 'course', + display_name: 'Demo Course', + id: 'COURSE_ID_101' + }; + return createXBlockInfo('section', outlineOptions, courseOutline); + }; + + /** + * Render breadcrumb and XBlock list view. + * + * @param {any} courseOutlineInfo course outline info + * @param {any} ancestorInfo ancestors info + */ + renderViews = function(courseOutlineInfo, ancestorInfo) { + moveXBlockBreadcrumbView = new MoveXBlockBreadcrumbView({}); + moveXBlockListView = new MoveXBlockListView( + { + model: new XBlockInfoModel(courseOutlineInfo, {parse: true}), + ancestorInfo: ancestorInfo || {ancestors: []} + } + ); + }; + + /** + * Extract displayed XBlock list info. + * + * @returns {Object} + */ + getDisplayedInfo = function() { + var viewEl = moveXBlockListView.$el; + return { + categoryText: viewEl.find('.category-text').text().trim(), + currentLocationText: viewEl.find('.current-location').text().trim(), + xblockCount: viewEl.find('.xblock-item').length, + xblockDisplayNames: viewEl.find('.xblock-item .xblock-displayname').map( + function() { return $(this).text().trim(); } + ).get(), + forwardButtonSRTexts: viewEl.find('.xblock-item .forward-sr-text').map( + function() { return $(this).text().trim(); } + ).get(), + forwardButtonCount: viewEl.find('.fa-arrow-right.forward-sr-icon').length + }; + }; + + /** + * Verify displayed XBlock list info. + * + * @param {String} category XBlock category + * @param {Integer} expectedXBlocksCount number of XBlock childs displayed + * @param {Boolean} hasCurrentLocation do we need to check current location + */ + verifyListViewInfo = function(category, expectedXBlocksCount, hasCurrentLocation) { + var displayedInfo = getDisplayedInfo(); + expect(displayedInfo.categoryText).toEqual(moveXBlockListView.categoriesText[category] + ':'); + expect(displayedInfo.xblockCount).toEqual(expectedXBlocksCount); + expect(displayedInfo.xblockDisplayNames).toEqual( + _.map(_.range(expectedXBlocksCount), function(xblockIndex) { + return category + '_display_name_' + xblockIndex; + }) + ); + if (category !== 'component') { + if (hasCurrentLocation) { + expect(displayedInfo.currentLocationText).toEqual('(Current location)'); + } + expect(displayedInfo.forwardButtonSRTexts).toEqual( + _.map(_.range(expectedXBlocksCount), function() { + return 'Click for children'; + }) + ); + expect(displayedInfo.forwardButtonCount).toEqual(expectedXBlocksCount); + } + }; + + /** + * Verify rendered breadcrumb info. + * + * @param {any} category XBlock category + * @param {any} xblockIndex XBlock index + */ + verifyBreadcrumbViewInfo = function(category, xblockIndex) { + var displayedBreadcrumbs = moveXBlockBreadcrumbView.$el.find('.breadcrumbs .bc-container').map( + function() { return $(this).text().trim(); } + ).get(), + categories = _.keys(parentChildMap).concat(['component']), + visitedCategories = categories.slice(0, _.indexOf(categories, category)); + + expect(displayedBreadcrumbs).toEqual( + _.map(visitedCategories, function(visitedCategory) { + return visitedCategory === 'course' ? + 'Course Outline' : visitedCategory + '_display_name_' + xblockIndex; + }) + ); + }; + + /** + * Click forward button in the list of displayed XBlocks. + * + * @param {any} buttonIndex forward button index + */ + clickForwardButton = function(buttonIndex) { + buttonIndex = buttonIndex || 0; // eslint-disable-line no-param-reassign + moveXBlockListView.$el.find('[data-item-index="' + buttonIndex + '"] button').click(); + }; + + /** + * Click on last clickable breadcrumb button. + */ + clickBreadcrumbButton = function() { + moveXBlockBreadcrumbView.$el.find('.bc-container button').last().click(); + }; + + /** + * Returns the parent or child category of current XBlock. + * + * @param {String} direction `forward` or `backward` + * @param {String} category XBlock category + * @returns {String} + */ + nextCategory = function(direction, category) { + return direction === 'forward' ? parentChildMap[category] : _.invert(parentChildMap)[category]; + }; + + /** + * Verify renderd info of breadcrumbs and XBlock list. + * + * @param {Object} outlineOptions options according to which outline was created + * @param {String} category XBlock category + * @param {Integer} buttonIndex forward button index + * @param {String} direction `forward` or `backward` + * @param {String} hasCurrentLocation do we need to check current location + * @returns + */ + verifyXBlockInfo = function(outlineOptions, category, buttonIndex, direction, hasCurrentLocation) { + var expectedXBlocksCount = outlineOptions[category]; + + verifyListViewInfo(category, expectedXBlocksCount, hasCurrentLocation); + verifyBreadcrumbViewInfo(category, buttonIndex); + + if (direction === 'forward') { + if (category === 'component') { + return; + } + clickForwardButton(buttonIndex); + } else if (direction === 'backward') { + if (category === 'section') { + return; + } + clickBreadcrumbButton(); + } + category = nextCategory(direction, category); // eslint-disable-line no-param-reassign + + verifyXBlockInfo(outlineOptions, category, buttonIndex, direction, hasCurrentLocation); + }; + + it('renders views with correct information', function() { + var outlineOptions = {section: 1, subsection: 1, unit: 1, component: 1}, + outline = createCourseOutline(outlineOptions); + + renderViews(outline); + verifyXBlockInfo(outlineOptions, 'section', 0, 'forward', false); + verifyXBlockInfo(outlineOptions, 'component', 0, 'backward', false); + }); + + it('shows correct behavior on breadcrumb navigation', function() { + var outline = createCourseOutline({section: 1, subsection: 1, unit: 1, component: 1}); + + renderViews(outline); + _.each(_.range(3), function() { + clickForwardButton(); + }); + + _.each(['component', 'unit', 'subsection', 'section'], function(category) { + verifyListViewInfo(category, 1); + if (category !== 'section') { + moveXBlockBreadcrumbView.$el.find('.bc-container button').last().click(); + } + }); + }); + + it('shows the correct current location', function() { + var outlineOptions = {section: 2, subsection: 2, unit: 2, component: 2}, + outline = createCourseOutline(outlineOptions), + ancestorInfo = { + ancestors: [ + { + category: 'vertical', + display_name: 'unit_display_name_0', + id: 'unit_ID' + }, + { + category: 'sequential', + display_name: 'subsection_display_name_0', + id: 'subsection_ID' + }, + { + category: 'chapter', + display_name: 'section_display_name_0', + id: 'section_ID' + }, + { + category: 'course', + display_name: 'Demo Course', + id: 'COURSE_ID_101' + } + ] + }; + + renderViews(outline, ancestorInfo); + verifyXBlockInfo(outlineOptions, 'section', 0, 'forward', true); + // click the outline breadcrumb to render sections + moveXBlockBreadcrumbView.$el.find('.bc-container button').first().click(); + verifyXBlockInfo(outlineOptions, 'section', 1, 'forward', false); + }); + + it('shows correct message when parent has no children', function() { + var outlinesInfo = [ + { + outline: createCourseOutline({}), + message: 'This course has no sections' + }, + { + outline: createCourseOutline({section: 1}), + message: 'This section has no subsections', + forwardClicks: 1 + }, + { + outline: createCourseOutline({section: 1, subsection: 1}), + message: 'This subsection has no units', + forwardClicks: 2 + }, + { + outline: createCourseOutline({section: 1, subsection: 1, unit: 1}), + message: 'This unit has no components', + forwardClicks: 3 + } + ]; + + _.each(outlinesInfo, function(info) { + renderViews(info.outline); + _.each(_.range(info.forwardClicks), function() { + clickForwardButton(); + }); + expect(moveXBlockListView.$el.find('.xblock-no-child-message').text().trim()).toEqual(info.message); + moveXBlockListView.undelegateEvents(); + moveXBlockBreadcrumbView.undelegateEvents(); + }); + }); + }); + }); diff --git a/cms/static/js/views/modals/base_modal.js b/cms/static/js/views/modals/base_modal.js index bd6f2981ff..2974a7720a 100644 --- a/cms/static/js/views/modals/base_modal.js +++ b/cms/static/js/views/modals/base_modal.js @@ -20,6 +20,7 @@ * button on the modal. * primaryActionButtonType: A string to be used as type for primary action button. * primaryActionButtonTitle: A string to be used as title for primary action button. + * showEditorModeButtons: Whether to show editor mode button in the modal header. */ define(['jquery', 'underscore', 'gettext', 'js/views/baseview'], function($, _, gettext, BaseView) { @@ -41,7 +42,8 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview'], viewSpecificClasses: '', addPrimaryActionButton: false, primaryActionButtonType: 'save', - primaryActionButtonTitle: gettext('Save') + primaryActionButtonTitle: gettext('Save'), + showEditorModeButtons: true }), initialize: function() { @@ -66,6 +68,8 @@ define(['jquery', 'underscore', 'gettext', 'js/views/baseview'], type: this.options.modalType, size: this.options.modalSize, title: this.getTitle(), + modalSRTitle: this.options.modalSRTitle, + showEditorModeButtons: this.options.showEditorModeButtons, viewSpecificClasses: this.options.viewSpecificClasses })); this.addActionButtons(); diff --git a/cms/static/js/views/modals/move_xblock_modal.js b/cms/static/js/views/modals/move_xblock_modal.js index 92f1363a36..8efabd6a43 100644 --- a/cms/static/js/views/modals/move_xblock_modal.js +++ b/cms/static/js/views/modals/move_xblock_modal.js @@ -4,27 +4,44 @@ define([ 'jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', 'js/views/modals/base_modal', + 'js/models/xblock_info', 'js/views/move_xblock_list', 'js/views/move_xblock_breadcrumb', 'common/js/components/views/feedback', 'edx-ui-toolkit/js/utils/string-utils', 'text!templates/move-xblock-modal.underscore' ], -function($, Backbone, _, gettext, BaseView, BaseModal, Feedback, StringUtils, MoveXblockModalTemplate) { +function($, Backbone, _, gettext, BaseView, BaseModal, XBlockInfoModel, MoveXBlockListView, MoveXBlockBreadcrumbView, + Feedback, StringUtils, MoveXblockModalTemplate) { 'use strict'; var MoveXblockModal = BaseModal.extend({ options: $.extend({}, BaseModal.prototype.options, { modalName: 'move-xblock', - modalSize: 'med', + modalSize: 'lg', + showEditorModeButtons: false, addPrimaryActionButton: true, primaryActionButtonType: 'move', - primaryActionButtonTitle: gettext('Move') + viewSpecificClasses: 'move-modal', + primaryActionButtonTitle: gettext('Move'), + modalSRTitle: gettext('Choose a location to move your component to') }), initialize: function() { + var self = this; BaseModal.prototype.initialize.call(this); + this.listenTo(Backbone, 'move:breadcrumbRendered', this.focusModal); this.sourceXBlockInfo = this.options.sourceXBlockInfo; - this.XBlockUrlRoot = this.options.sourceXBlockInfo; + this.XBlockURLRoot = this.options.XBlockURLRoot; + this.XBlockAncestorInfoURL = StringUtils.interpolate( + '{urlRoot}/{usageId}?fields=ancestorInfo', + {urlRoot: this.XBlockURLRoot, usageId: this.sourceXBlockInfo.get('id')} + ); + this.outlineURL = this.options.outlineURL; this.options.title = this.getTitle(); + this.fetchCourseOutline().done(function(courseOutlineInfo, ancestorInfo) { + $('.ui-loading').addClass('is-hidden'); + $('.breadcrumb-container').removeClass('is-hidden'); + self.renderViews(courseOutlineInfo, ancestorInfo); + }); }, getTitle: function() { @@ -40,12 +57,54 @@ function($, Backbone, _, gettext, BaseView, BaseModal, Feedback, StringUtils, Mo show: function() { BaseModal.prototype.show.apply(this, [false]); - Feedback.prototype.inFocus.apply(this, [this.options.modalWindowClass]); }, hide: function() { + if (this.moveXBlockListView) { + this.moveXBlockListView.remove(); + } + if (this.moveXBlockBreadcrumbView) { + this.moveXBlockBreadcrumbView.remove(); + } BaseModal.prototype.hide.apply(this); Feedback.prototype.outFocus.apply(this); + }, + + focusModal: function() { + Feedback.prototype.inFocus.apply(this, [this.options.modalWindowClass]); + $(this.options.modalWindowClass).focus(); + }, + + fetchCourseOutline: function() { + return $.when( + this.fetchData(this.outlineURL), + this.fetchData(this.XBlockAncestorInfoURL) + ); + }, + + fetchData: function(url) { + var deferred = $.Deferred(); + $.ajax({ + url: url, + contentType: 'application/json', + dataType: 'json', + type: 'GET' + }).done(function(data) { + deferred.resolve(data); + }).fail(function() { + deferred.reject(); + }); + return deferred.promise(); + }, + + renderViews: function(courseOutlineInfo, ancestorInfo) { + this.moveXBlockBreadcrumbView = new MoveXBlockBreadcrumbView({}); + this.moveXBlockListView = new MoveXBlockListView( + { + model: new XBlockInfoModel(courseOutlineInfo, {parse: true}), + ancestorInfo: ancestorInfo + } + ); } }); diff --git a/cms/static/js/views/move_xblock_breadcrumb.js b/cms/static/js/views/move_xblock_breadcrumb.js new file mode 100644 index 0000000000..2d891b3c5c --- /dev/null +++ b/cms/static/js/views/move_xblock_breadcrumb.js @@ -0,0 +1,52 @@ +/** + * MoveXBlockBreadcrumb show breadcrumbs to move back to parent. + */ +define([ + 'jquery', 'backbone', 'underscore', 'gettext', + 'edx-ui-toolkit/js/utils/html-utils', + 'edx-ui-toolkit/js/utils/string-utils', + 'text!templates/move-xblock-breadcrumb.underscore' +], +function($, Backbone, _, gettext, HtmlUtils, StringUtils, MoveXBlockBreadcrumbViewTemplate) { + 'use strict'; + + var MoveXBlockBreadcrumb = Backbone.View.extend({ + el: '.breadcrumb-container', + + defaultRenderOptions: { + breadcrumbs: ['Course Outline'] + }, + + events: { + 'click .parent-nav-button': 'handleBreadcrumbButtonPress' + }, + + initialize: function() { + this.template = HtmlUtils.template(MoveXBlockBreadcrumbViewTemplate); + this.listenTo(Backbone, 'move:childrenRendered', this.render); + }, + + render: function(options) { + HtmlUtils.setHtml( + this.$el, + this.template(_.extend({}, this.defaultRenderOptions, options)) + ); + Backbone.trigger('move:breadcrumbRendered'); + return this; + }, + + /** + * Event handler for breadcrumb button press. + * + * @param {Object} event + */ + handleBreadcrumbButtonPress: function(event) { + Backbone.trigger( + 'move:breadcrumbButtonPressed', + $(event.target).data('parentIndex') + ); + } + }); + + return MoveXBlockBreadcrumb; +}); diff --git a/cms/static/js/views/move_xblock_list.js b/cms/static/js/views/move_xblock_list.js new file mode 100644 index 0000000000..4e302de97f --- /dev/null +++ b/cms/static/js/views/move_xblock_list.js @@ -0,0 +1,201 @@ +/** + * XBlockListView shows list of XBlocks in a particular category(section, subsection, vertical etc). + */ +define([ + 'jquery', 'backbone', 'underscore', 'gettext', + 'edx-ui-toolkit/js/utils/html-utils', + 'edx-ui-toolkit/js/utils/string-utils', + 'js/views/utils/xblock_utils', + 'text!templates/move-xblock-list.underscore' +], +function($, Backbone, _, gettext, HtmlUtils, StringUtils, XBlockUtils, MoveXBlockListViewTemplate) { + 'use strict'; + + var XBlockListView = Backbone.View.extend({ + el: '.xblock-list-container', + + // parent info of currently displayed children + parentInfo: {}, + // currently displayed children XBlocks info + childrenInfo: {}, + // list of visited parent XBlocks, needed for backward navigation + visitedAncestors: null, + + // parent to child relation map + categoryRelationMap: { + course: 'section', + section: 'subsection', + subsection: 'unit', + unit: 'component' + }, + + categoriesText: { + section: gettext('Sections'), + subsection: gettext('Subsections'), + unit: gettext('Units'), + component: gettext('Components') + }, + + events: { + 'click .button-forward': 'renderChildren' + }, + + initialize: function(options) { + this.visitedAncestors = []; + this.template = HtmlUtils.template(MoveXBlockListViewTemplate); + this.ancestorInfo = options.ancestorInfo; + this.listenTo(Backbone, 'move:breadcrumbButtonPressed', this.handleBreadcrumbButtonPress); + this.renderXBlockInfo(); + }, + + render: function() { + HtmlUtils.setHtml( + this.$el, + this.template( + { + xblocks: this.childrenInfo.children, + noChildText: this.getNoChildText(), + categoryText: this.getCategoryText(), + parentDisplayname: this.parentInfo.parent.get('display_name'), + XBlocksCategory: this.childrenInfo.category, + currentLocationIndex: this.getCurrentLocationIndex() + } + ) + ); + Backbone.trigger('move:childrenRendered', this.breadcrumbInfo()); + return this; + }, + + /** + * Forward button press handler. This will render all the childs of an XBlock. + * + * @param {Object} event + */ + renderChildren: function(event) { + this.renderXBlockInfo( + 'forward', + $(event.target).closest('.xblock-item').data('itemIndex') + ); + }, + + /** + * Breadcrumb button press event handler. Render all the childs of an XBlock. + * + * @param {any} newParentIndex Index of a parent XBlock + */ + handleBreadcrumbButtonPress: function(newParentIndex) { + this.renderXBlockInfo('backward', newParentIndex); + }, + + /** + * Render XBlocks based on `forward` or `backward` navigation. + * + * @param {any} direction `forward` or `backward` + * @param {any} newParentIndex Index of a parent XBlock + */ + renderXBlockInfo: function(direction, newParentIndex) { + if (direction === undefined) { + this.parentInfo.parent = this.model; + } else if (direction === 'forward') { + // clicked child is the new parent + this.parentInfo.parent = this.childrenInfo.children[newParentIndex]; + } else if (direction === 'backward') { + // new parent will be one of visitedAncestors + this.parentInfo.parent = this.visitedAncestors[newParentIndex]; + // remove visited ancestors + this.visitedAncestors.splice(newParentIndex); + } + + this.visitedAncestors.push(this.parentInfo.parent); + + if (this.parentInfo.parent.get('child_info')) { + this.childrenInfo.children = this.parentInfo.parent.get('child_info').children; + } else { + this.childrenInfo.children = []; + } + + this.setDisplayedXBlocksCategories(); + this.render(); + }, + + /** + * Set parent and child XBlock categories. + */ + setDisplayedXBlocksCategories: function() { + this.parentInfo.category = XBlockUtils.getXBlockType( + this.parentInfo.parent.get('category'), + this.visitedAncestors[this.visitedAncestors.length - 2] + ); + this.childrenInfo.category = this.categoryRelationMap[this.parentInfo.category]; + }, + + /** + * Get index of source XBlock. + * + * @returns {any} Integer or undefined + */ + getCurrentLocationIndex: function() { + var category, ancestorXBlock, currentLocationIndex; + + if (this.childrenInfo.category === 'component' || this.childrenInfo.children.length === 0) { + return currentLocationIndex; + } + + category = this.childrenInfo.children[0].get('category'); + ancestorXBlock = _.find( + this.ancestorInfo.ancestors, function(ancestor) { return ancestor.category === category; } + ); + + if (ancestorXBlock) { + _.each(this.childrenInfo.children, function(xblock, index) { + if (ancestorXBlock.display_name === xblock.get('display_name') && + ancestorXBlock.id === xblock.get('id')) { + currentLocationIndex = index; + } + }); + } + + return currentLocationIndex; + }, + + /** + * Get category text for currently displayed children. + * + * @returns {String} + */ + getCategoryText: function() { + return this.categoriesText[this.childrenInfo.category]; + }, + + /** + * Get text when a parent XBlock has no children. + * + * @returns {String} + */ + getNoChildText: function() { + return StringUtils.interpolate( + gettext('This {parentCategory} has no {childCategory}'), + { + parentCategory: this.parentInfo.category, + childCategory: this.categoriesText[this.childrenInfo.category].toLowerCase() + } + ); + }, + + /** + * Construct breadcurmb info. + * + * @returns {Object} + */ + breadcrumbInfo: function() { + return { + breadcrumbs: _.map(this.visitedAncestors, function(ancestor) { + return ancestor.get('category') === 'course' ? + gettext('Course Outline') : ancestor.get('display_name'); + }) + }; + } + }); + + return XBlockListView; +}); diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index 1a476bcde4..05f6409deb 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -196,7 +196,8 @@ define(['jquery', 'underscore', 'gettext', 'js/views/pages/base_page', 'common/j var xblockElement = this.findXBlockElement(event.target), modal = new MoveXBlockModal({ sourceXBlockInfo: XBlockUtils.findXBlockInfo(xblockElement, this.model), - XBlockUrlRoot: this.getURLRoot() + XBlockURLRoot: this.getURLRoot(), + outlineURL: this.options.outlineURL }); event.preventDefault(); diff --git a/cms/static/sass/elements/_controls.scss b/cms/static/sass/elements/_controls.scss index e3afc0e16d..00f23ae0cd 100644 --- a/cms/static/sass/elements/_controls.scss +++ b/cms/static/sass/elements/_controls.scss @@ -344,6 +344,20 @@ .btn-default.edit-button { font-weight: 300; } + + .stack-move-icon { + font-size: 0.52em; + + @include rtl { + .fa-file-o { + @include transform(rotateY(180deg)); + } + + .fa-arrow-right { + @include transform(rotate(180deg)); + } + } + } } } diff --git a/cms/static/sass/elements/_modal-window.scss b/cms/static/sass/elements/_modal-window.scss index 4a5ec294c9..893ac32d17 100644 --- a/cms/static/sass/elements/_modal-window.scss +++ b/cms/static/sass/elements/_modal-window.scss @@ -285,6 +285,20 @@ // specific modal overrides // ------------------------ + // Move XBlock Modal + .modal-window.move-modal { + top: 10% !important; + } + + .move-xblock-modal { + .modal-content { + padding: ($baseline/2) ($baseline/2) ($baseline*1.25) ($baseline/2); + } + .ui-loading { + box-shadow: none; + } + } + // upload modal .assetupload-modal { diff --git a/cms/static/sass/partials/_variables.scss b/cms/static/sass/partials/_variables.scss index 69912569b1..473fc49b5c 100644 --- a/cms/static/sass/partials/_variables.scss +++ b/cms/static/sass/partials/_variables.scss @@ -278,3 +278,5 @@ $body-line-height: golden-ratio(.875em, 1); // carried over from LMS for xmodules $action-primary-active-bg: #1AA1DE !default; // $m-blue $very-light-text: $white !default; + +$color-background-alternate: rgb(242, 248, 251) !default; diff --git a/cms/static/sass/views/_container.scss b/cms/static/sass/views/_container.scss index 3c26cf1a0b..84a9264c59 100644 --- a/cms/static/sass/views/_container.scss +++ b/cms/static/sass/views/_container.scss @@ -331,3 +331,114 @@ } } } + + +.move-xblock-modal { + + button { + background: transparent; + border-color: transparent; + padding: 0; + border: none; + } + + .breadcrumb-container { + margin-bottom: ($baseline/4); + border: 1px solid $btn-lms-border; + padding: ($baseline/2); + background: $color-background-alternate; + + .breadcrumbs { + + .bc-container { + @include font-size(14); + display: inline-block; + + .breadcrumb-fa-icon { + padding: 0 ($baseline/4); + + @include rtl { + @include transform(rotate(180deg)); + } + } + + &.last { + .parent-displayname { + @include font-size(18); + } + } + } + + .bc-container:not(.last) { + button, .parent-displayname { + text-decoration: underline; + color: $ui-link-color; + } + } + } + } + + .category-text { + @include margin-left($baseline/2); + @include font-size(14); + color: $black; + } + + .xblock-items-container { + max-height: ($baseline*15); + overflow-y: auto; + + .xblock-item { + & > * { + width: 100%; + color: $uxpl-blue-hover-active; + } + + .component { + display: block; + color: $black; + } + + .button-forward, .component { + border: none; + padding: ($baseline/2); + } + + .button-forward { + .xblock-displayname { + @include float(left); + } + + .forward-sr-icon { + @include float(right); + + @include rtl { + @include transform(rotate(180deg)); + } + } + + &:hover, &:focus { + background: $color-background-alternate; + } + } + } + + .xblock-no-child-message { + @include text-align(center); + display: block; + padding: ($baseline*2); + } + } + + .truncate { + max-width: 90%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .current-location { + @include float(left); + @include margin-left($baseline); + } +} diff --git a/cms/templates/component.html b/cms/templates/component.html index 900b9d5fae..c42e371898 100644 --- a/cms/templates/component.html +++ b/cms/templates/component.html @@ -21,8 +21,11 @@
  • diff --git a/cms/templates/container.html b/cms/templates/container.html index ebabe6764e..a313733c3b 100644 --- a/cms/templates/container.html +++ b/cms/templates/container.html @@ -43,7 +43,8 @@ from openedx.core.djangolib.markup import HTML, Text "${action | n, js_escaped_string}", { isUnitPage: ${is_unit_page | n, dump_js_escaped_json}, - canEdit: true + canEdit: true, + outlineURL: "${outline_url | n, js_escaped_string}" } ); }); diff --git a/cms/templates/js/basic-modal.underscore b/cms/templates/js/basic-modal.underscore index 84bdb2b339..4273fe4f99 100644 --- a/cms/templates/js/basic-modal.underscore +++ b/cms/templates/js/basic-modal.underscore @@ -5,9 +5,18 @@
  • % endif