Commit c7dc83c3 by muhammad-ammar Committed by Mushtaq Ali

Move modal show course outline with breadcrumb

TNL-6060
parent bfeeeff7
......@@ -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,
......
......@@ -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',
......
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) {
'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';
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: 'testCourse/branch/draft/block/verticalFFF',
id: 'USAGE_ID',
display_name: DISPLAY_NAME,
category: 'html'
}),
XBlockUrlRoot: '/xblock'
XBlockURLRoot: '/xblock',
outlineURL: OUTLINE_URL,
XBlockAncestorInfoURL: ANCESTORS_URL
});
modal.show();
};
beforeEach(function() {
setFixtures('<div id="page-notification"></div><div id="reader-feedback"></div>');
TemplateHelpers.installTemplates([
'basic-modal',
'modal-button',
'move-xblock-modal'
]);
showModal();
});
afterEach(function() {
modal.hide();
});
it('rendered as expected', function() {
expect(modal.$el.find('.modal-header .title').text()).toEqual('Move: ' + DISPLAY_NAME);
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");
});
});
});
......@@ -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();
......
......@@ -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
}
);
}
});
......
/**
* 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;
});
/**
* 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;
});
......@@ -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();
......
......@@ -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));
}
}
}
}
}
......
......@@ -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 {
......
......@@ -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;
......@@ -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);
}
}
......@@ -21,8 +21,11 @@
</li>
<li class="action-item action-move">
<button data-tooltip="${_("Move")}" class="btn-default move-button action-button">
<span class="icon fa fa-folder-o" aria-hidden="true"></span>
<span class="sr">${_("Move")}</span>
<span class="stack-move-icon fa-stack fa-lg">
<span class="fa fa-file-o fa-stack-2x fa-fw" aria-hidden="true"></span>
<span class="fa fa-arrow-right fa-stack-1x fa-fw" aria-hidden="true"></span>
</span>
<span class="sr">${_("Move")}</span>
</button>
</li>
<li class="action-item action-delete">
......
......@@ -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}"
}
);
});
......
......@@ -5,9 +5,18 @@
<div class="modal-window <%- viewSpecificClasses %> modal-<%- size %> modal-type-<%- type %>" tabindex="-1" aria-labelledby="modal-window-title">
<div class="<%- name %>-modal">
<div class="modal-header">
<h2 id="modal-window-title" class="title modal-window-title"><%- title %></h2>
<ul class="editor-modes action-list action-modes">
</ul>
<h2 id="modal-window-title" class="title modal-window-title">
<%- title %>
<% if (modalSRTitle) { %>
<span class="sr modal-sr-title">
<%- modalSRTitle %>
</span>
<% } %>
</h2>
<% if (showEditorModeButtons) { %>
<ul class="editor-modes action-list action-modes">
</ul>
<% } %>
</div>
<div class="modal-content">
</div>
......
<nav class="breadcrumbs" aria-label="Course Outline breadcrumb">
<% _.each(breadcrumbs.slice(0, -1), function (breadcrumb, index, items) { %>
<ol class="bc-container bc-<%- index %>">
<li class="bc-container-content">
<button class="parent-nav-button" data-parent-index="<%- index %>">
<%- breadcrumb %>
</button>
<span class="fa fa-angle-right breadcrumb-fa-icon" aria-hidden="true"></span>
</li>
</ol>
<% }) %>
<ol class="bc-container bc-<%- breadcrumbs.length - 1 %> last">
<li class="bc-container-content">
<span class="parent-displayname"><%- breadcrumbs[breadcrumbs.length - 1] %></span>
</li>
</ol>
</nav>
<div class="xblock-items-category">
<span class="sr">
<%
// Translators: message will be like `Units in Homework - Question Styles`, `Subsections in Example 1 - Getting started` etc.
%>
<%- StringUtils.interpolate(
gettext("{categoryText} in {parentDisplayname}"),
{categoryText: categoryText, parentDisplayname: parentDisplayname}
)
%>
</span>
<span class="category-text" aria-hidden="true">
<%- categoryText %>:
</span>
</div>
<ul class="xblock-items-container">
<% for (var i = 0; i < xblocks.length; i++) {
var xblock = xblocks[i];
%>
<li class="xblock-item" data-item-index="<%- i %>">
<% if (XBlocksCategory === 'component') { %>
<span class="xblock-displayname component truncate">
<%- xblock.get('display_name') %>
</span>
<% } else { %>
<button class="button-forward" >
<span class="xblock-displayname truncate">
<%- xblock.get('display_name') %>
</span>
<% if(currentLocationIndex === i) { %>
<span class="current-location">
(<%- gettext('Current location') %>)
</span>
<% } %>
<span class="icon fa fa-arrow-right forward-sr-icon" aria-hidden="true"></span>
<span class="sr forward-sr-text"><%- gettext("Click for children") %></span>
</button>
<% } %>
</li>
<% } %>
<% if(xblocks.length === 0) { %>
<span class="xblock-no-child-message">
<%- noChildText %>
</span>
<% } %>
</ul>
<div class='breadcrumb-container'>
</div>
<div class='treeview-container'>
<div class="ui-loading">
<p>
<span class="spin">
<span class="icon fa fa-refresh" aria-hidden="true"></span>
</span>
<span class="copy"><%- gettext('Loading') %></span>
</p>
</div>
<div class='breadcrumb-container is-hidden'></div>
<div class='xblock-list-container'></div>
......@@ -92,8 +92,11 @@ messages = xblock.validate().to_json()
<li class="action-item action-move">
<button data-tooltip="${_("Move")}" class="btn-default move-button action-button">
<span class="icon fa fa-folder-o" aria-hidden="true"></span>
<span class="sr">${_("Move")}</span>
<span class="stack-move-icon fa-stack fa-lg ">
<span class="fa fa-file-o fa-stack-2x fa-fw" aria-hidden="true"></span>
<span class="fa fa-arrow-right fa-stack-1x fa-fw" aria-hidden="true"></span>
</span>
<span class="sr">${_("Move")}</span>
</button>
</li>
% endif
......
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