From e856f07b875b51eaff391b484901ccebecf5fb83 Mon Sep 17 00:00:00 2001 From: Mushtaq Ali Date: Fri, 20 Jan 2017 18:39:04 +0500 Subject: [PATCH] Move and undo move XBlock - TNL-6062 - TNL-6229 --- cms/static/cms/js/main.js | 12 +- .../views/modals/move_xblock_modal_spec.js | 224 +++++++++++++++++- .../js/views/modals/move_xblock_modal.js | 67 +++++- cms/static/js/views/pages/container.js | 4 +- .../js/views/utils/move_xblock_utils.js | 69 ++++++ cms/static/js/views/utils/xblock_utils.js | 32 ++- .../common/js/components/views/feedback.js | 2 + .../js/components/views/feedback_move.js | 49 ++++ .../components/system-feedback.underscore | 3 +- 9 files changed, 449 insertions(+), 13 deletions(-) create mode 100644 cms/static/js/views/utils/move_xblock_utils.js create mode 100644 common/static/common/js/components/views/feedback_move.js diff --git a/cms/static/cms/js/main.js b/cms/static/cms/js/main.js index 74e654241b..6a463521c0 100644 --- a/cms/static/cms/js/main.js +++ b/cms/static/cms/js/main.js @@ -6,7 +6,7 @@ 'common/js/components/views/feedback_notification', 'coffee/src/ajax_prefix', 'jquery.cookie'], function(domReady, $, str, Backbone, gettext, NotificationView) { - var main; + var main, sendJSON; main = function() { AjaxPrefix.addAjaxPrefix(jQuery, function() { return $("meta[name='path_prefix']").attr('content'); @@ -45,20 +45,26 @@ }); return msg.show(); }); - $.postJSON = function(url, data, callback) { + sendJSON = function(url, data, callback, type) { // eslint-disable-line no-param-reassign if ($.isFunction(data)) { callback = data; data = undefined; } return $.ajax({ url: url, - type: 'POST', + type: type, contentType: 'application/json; charset=utf-8', dataType: 'json', data: JSON.stringify(data), success: callback }); }; + $.postJSON = function(url, data, callback) { + return sendJSON(url, data, callback, 'POST'); + }; + $.patchJSON = function(url, data, callback) { + return sendJSON(url, data, callback, 'PATCH'); + }; return domReady(function() { if (window.onTouchBasedDevice()) { return $('body').addClass('touch-based-device'); 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 index 28bb1792cf..64a89aed4b 100644 --- a/cms/static/js/spec/views/modals/move_xblock_modal_spec.js +++ b/cms/static/js/spec/views/modals/move_xblock_modal_spec.js @@ -1,8 +1,24 @@ 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) { + 'js/views/modals/move_xblock_modal', 'edx-ui-toolkit/js/utils/html-utils', + 'edx-ui-toolkit/js/utils/string-utils', 'js/models/xblock_info'], + function($, _, AjaxHelpers, TemplateHelpers, ViewHelpers, MoveXBlockModal, HtmlUtils, StringUtils, XBlockInfo) { 'use strict'; + + var modal, + showModal, + verifyNotificationStatus, + selectTargetParent, + getConfirmationFeedbackTitle, + getUndoConfirmationFeedbackTitle, + getConfirmationFeedbackTitleHtml, + getConfirmationFeedbackMessageHtml, + sourceDisplayName = 'HTML 101', + outlineUrl = '/course/cid?formats=concise', + sourceLocator = 'source-xblock-locator', + targetParentLocator = 'target-parent-xblock-locator', + sourceParentLocator = 'source-parent-xblock-locator'; + describe('MoveXBlockModal', function() { var modal, showModal, @@ -42,7 +58,7 @@ define(['jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpe showModal(); expect( modal.$el.find('.modal-header .title').contents().get(0).nodeValue.trim() - ).toEqual('Move: ' + DISPLAY_NAME); + ).toEqual('Move: ' + sourceDisplayName); expect( modal.$el.find('.modal-sr-title').text().trim() ).toEqual('Choose a location to move your component to'); @@ -56,7 +72,7 @@ define(['jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpe 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.expectRequest(requests, 'GET', outlineUrl); AjaxHelpers.respondWithJson(requests, {}); AjaxHelpers.expectRequest(requests, 'GET', ANCESTORS_URL); AjaxHelpers.respondWithJson(requests, {}); @@ -72,4 +88,204 @@ define(['jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpe ViewHelpers.verifyNotificationShowing(notificationSpy, "Studio's having trouble saving your work"); }); }); + + showModal = function() { + modal = new MoveXBlockModal({ + sourceXBlockInfo: new XBlockInfo({ + id: sourceLocator, + display_name: sourceDisplayName, + category: 'html' + }), + sourceParentXBlockInfo: new XBlockInfo({ + id: sourceParentLocator, + display_name: 'VERT 101', + category: 'vertical' + }), + XBlockUrlRoot: '/xblock', + outlineURL: outlineUrl + }); + modal.show(); + }; + + selectTargetParent = function(parentLocator) { + modal.moveXBlockListView = { + parent_info: { + parent: { + id: parentLocator + } + }, + remove: function() {} // attach a fake remove method + }; + }; + + getConfirmationFeedbackTitle = function(displayName) { + return StringUtils.interpolate( + 'Success! "{displayName}" has been moved.', + { + displayName: displayName + } + ); + }; + + getUndoConfirmationFeedbackTitle = function(displayName) { + return StringUtils.interpolate( + 'Move cancelled. "{sourceDisplayName}" has been moved back to its original location.', + { + sourceDisplayName: displayName + } + ); + }; + + getConfirmationFeedbackTitleHtml = function(parentLocator) { + return StringUtils.interpolate( + '{link_start}Take me to the new location{link_end}', + { + link_start: HtmlUtils.HTML(''), + link_end: HtmlUtils.HTML('') + } + ); + }; + + getConfirmationFeedbackMessageHtml = function(displayName, locator, parentLocator, sourceIndex) { + return HtmlUtils.interpolateHtml( + HtmlUtils.HTML( + '{undoMove}'), + { + displayName: displayName, + sourceLocator: locator, + parentSourceLocator: parentLocator, + targetIndex: sourceIndex, + undoMove: gettext('Undo move') + } + ); + }; + + verifyNotificationStatus = function(requests, notificationSpy, notificationText, sourceIndex) { + var sourceIndex = sourceIndex || 0; // eslint-disable-line no-redeclare + ViewHelpers.verifyNotificationShowing(notificationSpy, notificationText); + AjaxHelpers.respondWithJson(requests, { + move_source_locator: sourceLocator, + parent_locator: sourceParentLocator, + target_index: sourceIndex + }); + ViewHelpers.verifyNotificationHidden(notificationSpy); + }; + + describe('Move an xblock', function() { + var sendMoveXBlockRequest, + moveXBlockWithSuccess; + + beforeEach(function() { + TemplateHelpers.installTemplates([ + 'basic-modal', + 'modal-button', + 'move-xblock-modal' + ]); + showModal(); + }); + + afterEach(function() { + modal.hide(); + }); + + sendMoveXBlockRequest = function(requests, xblockLocator, parentLocator, targetIndex, sourceIndex) { + var responseData, + expectedData, + sourceIndex = sourceIndex || 0, // eslint-disable-line no-redeclare + moveButton = modal.$el.find('.modal-actions .action-move')[sourceIndex]; + + // select a target item and click + selectTargetParent(parentLocator); + moveButton.click(); + + responseData = expectedData = { + move_source_locator: xblockLocator, + parent_locator: parentLocator + }; + + if (targetIndex !== undefined) { + expectedData = _.extend(expectedData, { + targetIndex: targetIndex + }); + } + + // verify content of request + AjaxHelpers.expectJsonRequest(requests, 'PATCH', '/xblock/', expectedData); + + // send the response + AjaxHelpers.respondWithJson(requests, _.extend(responseData, { + source_index: sourceIndex + })); + }; + + moveXBlockWithSuccess = function(requests) { + var sourceIndex = 0; + sendMoveXBlockRequest(requests, sourceLocator, targetParentLocator); + expect(modal.movedAlertView).toBeDefined(); + expect(modal.movedAlertView.options.title).toEqual(getConfirmationFeedbackTitle(sourceDisplayName)); + expect(modal.movedAlertView.options.titleHtml).toEqual( + getConfirmationFeedbackTitleHtml(targetParentLocator) + ); + expect(modal.movedAlertView.options.messageHtml).toEqual( + getConfirmationFeedbackMessageHtml( + sourceDisplayName, + sourceLocator, + sourceParentLocator, + sourceIndex + ) + ); + }; + + it('moves an xblock when move button is clicked', function() { + var requests = AjaxHelpers.requests(this); + moveXBlockWithSuccess(requests); + }); + + it('undo move an xblock when undo move button is clicked', function() { + var sourceIndex = 0, + requests = AjaxHelpers.requests(this); + moveXBlockWithSuccess(requests); + modal.movedAlertView.undoMoveXBlock({ + target: $(modal.movedAlertView.options.messageHtml.text) + }); + AjaxHelpers.respondWithJson(requests, { + move_source_locator: sourceLocator, + parent_locator: sourceParentLocator, + target_index: sourceIndex + }); + expect(modal.movedAlertView.movedAlertView.options.title).toEqual( + getUndoConfirmationFeedbackTitle(sourceDisplayName) + ); + }); + + it('does not move an xblock when cancel button is clicked', function() { + var sourceIndex = 0; + // select a target parent and click cancel button + selectTargetParent(targetParentLocator); + modal.$el.find('.modal-actions .action-cancel')[sourceIndex].click(); + expect(modal.movedAlertView).toBeNull(); + }); + + it('shows a notification when moving', function() { + var requests = AjaxHelpers.requests(this), + notificationSpy = ViewHelpers.createNotificationSpy(); + // select a target item and click on move + selectTargetParent(targetParentLocator); + modal.$el.find('.modal-actions .action-move').click(); + verifyNotificationStatus(requests, notificationSpy, 'Moving'); + }); + + it('shows a notification when undo moving', function() { + var notificationSpy, + requests = AjaxHelpers.requests(this); + moveXBlockWithSuccess(requests); + notificationSpy = ViewHelpers.createNotificationSpy(); + modal.movedAlertView.undoMoveXBlock({ + target: $(modal.movedAlertView.options.messageHtml.text) + }); + verifyNotificationStatus(requests, notificationSpy, 'Undo moving'); + }); + }); }); diff --git a/cms/static/js/views/modals/move_xblock_modal.js b/cms/static/js/views/modals/move_xblock_modal.js index 8efabd6a43..acb45caf3f 100644 --- a/cms/static/js/views/modals/move_xblock_modal.js +++ b/cms/static/js/views/modals/move_xblock_modal.js @@ -6,14 +6,23 @@ define([ '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', + 'js/views/utils/xblock_utils', + 'js/views/utils/move_xblock_utils', + 'edx-ui-toolkit/js/utils/html-utils', 'edx-ui-toolkit/js/utils/string-utils', 'text!templates/move-xblock-modal.underscore' ], function($, Backbone, _, gettext, BaseView, BaseModal, XBlockInfoModel, MoveXBlockListView, MoveXBlockBreadcrumbView, - Feedback, StringUtils, MoveXblockModalTemplate) { + Feedback, XBlockViewUtils, MoveXBlockUtils, HtmlUtils, StringUtils, MoveXblockModalTemplate) { 'use strict'; var MoveXblockModal = BaseModal.extend({ + modalSRTitle: gettext('Choose a location to move your component to'), + + events: _.extend({}, BaseModal.prototype.events, { + 'click .action-move': 'moveXBlock' + }), + options: $.extend({}, BaseModal.prototype.options, { modalName: 'move-xblock', modalSize: 'lg', @@ -30,6 +39,7 @@ function($, Backbone, _, gettext, BaseView, BaseModal, XBlockInfoModel, MoveXBlo BaseModal.prototype.initialize.call(this); this.listenTo(Backbone, 'move:breadcrumbRendered', this.focusModal); this.sourceXBlockInfo = this.options.sourceXBlockInfo; + this.sourceParentXBlockInfo = this.options.sourceParentXBlockInfo; this.XBlockURLRoot = this.options.XBlockURLRoot; this.XBlockAncestorInfoURL = StringUtils.interpolate( '{urlRoot}/{usageId}?fields=ancestorInfo', @@ -42,12 +52,16 @@ function($, Backbone, _, gettext, BaseView, BaseModal, XBlockInfoModel, MoveXBlo $('.breadcrumb-container').removeClass('is-hidden'); self.renderViews(courseOutlineInfo, ancestorInfo); }); + this.targetParentXBlockInfo = null; + this.movedAlertView = null; + this.moveXBlockBreadcrumbView = null; + this.moveXBlockListView = null; }, getTitle: function() { return StringUtils.interpolate( - gettext('Move: {display_name}'), - {display_name: this.sourceXBlockInfo.get('display_name')} + gettext('Move: {displayName}'), + {displayName: this.sourceXBlockInfo.get('display_name')} ); }, @@ -57,6 +71,7 @@ function($, Backbone, _, gettext, BaseView, BaseModal, XBlockInfoModel, MoveXBlo show: function() { BaseModal.prototype.show.apply(this, [false]); + Feedback.prototype.inFocus.apply(this, [this.options.modalWindowClass]); }, hide: function() { @@ -105,6 +120,52 @@ function($, Backbone, _, gettext, BaseView, BaseModal, XBlockInfoModel, MoveXBlo ancestorInfo: ancestorInfo } ); + }, + + moveXBlock: function() { + var self = this; + XBlockViewUtils.moveXBlock(self.sourceXBlockInfo.id, self.moveXBlockListView.parent_info.parent.id) + .done(function(response) { + if (response.move_source_locator) { + // hide modal + self.hide(); + // hide xblock element + $("li.studio-xblock-wrapper[data-locator='" + self.sourceXBlockInfo.id + "']").hide(); + if (self.movedAlertView) { + self.movedAlertView.hide(); + } + self.movedAlertView = MoveXBlockUtils.showMovedNotification( + StringUtils.interpolate( + gettext('Success! "{displayName}" has been moved.'), + { + displayName: self.sourceXBlockInfo.get('display_name') + } + ), + StringUtils.interpolate( + gettext('{link_start}Take me to the new location{link_end}'), + { + link_start: HtmlUtils.HTML(''), + link_end: HtmlUtils.HTML('') + } + ), + HtmlUtils.interpolateHtml( + HtmlUtils.HTML( + '{undoMove}' + ), + { + displayName: self.sourceXBlockInfo.get('display_name'), + sourceLocator: self.sourceXBlockInfo.id, + sourceParentLocator: self.sourceParentXBlockInfo.id, + targetIndex: response.source_index, + undoMove: gettext('Undo move') + } + ) + ); + } + }); } }); diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index 05f6409deb..1f89554e3a 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -194,9 +194,11 @@ define(['jquery', 'underscore', 'gettext', 'js/views/pages/base_page', 'common/j showMoveXBlockModal: function(event) { var xblockElement = this.findXBlockElement(event.target), + parentXBlockElement = xblockElement.parents('.studio-xblock-wrapper'), modal = new MoveXBlockModal({ sourceXBlockInfo: XBlockUtils.findXBlockInfo(xblockElement, this.model), - XBlockURLRoot: this.getURLRoot(), + sourceParentXBlockInfo: XBlockUtils.findXBlockInfo(parentXBlockElement, this.model), + XBlockUrlRoot: this.getURLRoot(), outlineURL: this.options.outlineURL }); diff --git a/cms/static/js/views/utils/move_xblock_utils.js b/cms/static/js/views/utils/move_xblock_utils.js new file mode 100644 index 0000000000..22ee26fb9c --- /dev/null +++ b/cms/static/js/views/utils/move_xblock_utils.js @@ -0,0 +1,69 @@ +/** + * Provides utilities for move xblock. + */ +define(['jquery', 'underscore', 'common/js/components/views/feedback_alert', 'js/views/utils/xblock_utils', + 'js/views/utils/move_xblock_utils', 'edx-ui-toolkit/js/utils/string-utils'], + function($, _, AlertView, XBlockViewUtils, MoveXBlockUtils, StringUtils) { + 'use strict'; + var MovedAlertView, showMovedNotification; + + MovedAlertView = AlertView.Confirmation.extend({ + events: _.extend({}, AlertView.Confirmation.prototype.events, { + 'click .action-undo-move': 'undoMoveXBlock' + }), + + options: $.extend({}, AlertView.Confirmation.prototype.options), + + initialize: function() { + AlertView.prototype.initialize.apply(this, arguments); + this.movedAlertView = null; + }, + + undoMoveXBlock: function(event) { + var self = this, + $moveButton = $(event.target), + sourceLocator = $moveButton.data('source-locator'), + sourceDisplayName = $moveButton.data('source-display-name'), + sourceParentLocator = $moveButton.data('source-parent-locator'), + targetIndex = $moveButton.data('target-index'); + XBlockViewUtils.moveXBlock(sourceLocator, sourceParentLocator, targetIndex) + .done(function(response) { + // show XBlock element + $('.studio-xblock-wrapper[data-locator="' + response.move_source_locator + '"]').show(); + if (self.movedAlertView) { + self.movedAlertView.hide(); + } + self.movedAlertView = showMovedNotification( + StringUtils.interpolate( + gettext('Move cancelled. "{sourceDisplayName}" has been moved back to its original ' + + 'location.'), + { + sourceDisplayName: sourceDisplayName + } + ) + ); + }); + } + }); + + showMovedNotification = function(title, titleHtml, messageHtml) { + var movedAlertView = new MovedAlertView({ + title: title, + titleHtml: titleHtml, + messageHtml: messageHtml, + maxShown: 10000 + }); + movedAlertView.show(); + // scroll to top + $.smoothScroll({ + offset: 0, + easing: 'swing', + speed: 1000 + }); + return movedAlertView; + }; + + return { + showMovedNotification: showMovedNotification + }; + }); diff --git a/cms/static/js/views/utils/xblock_utils.js b/cms/static/js/views/utils/xblock_utils.js index be1f1faadd..addf611bf7 100644 --- a/cms/static/js/views/utils/xblock_utils.js +++ b/cms/static/js/views/utils/xblock_utils.js @@ -6,7 +6,8 @@ define(['jquery', 'underscore', 'gettext', 'common/js/components/utils/view_util function($, _, gettext, ViewUtils, ModuleUtils, XBlockInfo, StringUtils) { 'use strict'; var addXBlock, duplicateXBlock, deleteXBlock, createUpdateRequestData, updateXBlockField, VisibilityState, - getXBlockVisibilityClass, getXBlockListTypeClass, updateXBlockFields, getXBlockType, findXBlockInfo; + getXBlockVisibilityClass, getXBlockListTypeClass, updateXBlockFields, getXBlockType, findXBlockInfo, + moveXBlock; /** * Represents the possible visibility states for an xblock: @@ -91,6 +92,34 @@ define(['jquery', 'underscore', 'gettext', 'common/js/components/utils/view_util }); }; + /** + * Moves the specified xblock in a new parent xblock. + * @param {String} sourceLocator The xblock element to be moved. + * @param {String} targetParentLocator Target parent xblock locator of the xblock to be moved, + * new moved xblock would be placed under this xblock. + * @param {String} targetIndex Intended index position of the xblock in parent xblock. If provided, + * xblock would be placed at the particular index in the parent xblock. + * @returns {jQuery promise} A promise representing the moving of the xblock. + */ + moveXBlock = function(sourceLocator, targetParentLocator, targetIndex) { + var moveOperation = $.Deferred(), + operationText = targetIndex !== undefined ? gettext('Undo moving') : gettext('Moving'); + return ViewUtils.runOperationShowingMessage(operationText, + function() { + $.patchJSON(ModuleUtils.getUpdateUrl(), { + move_source_locator: sourceLocator, + parent_locator: targetParentLocator, + target_index: targetIndex + }, function(data) { + moveOperation.resolve(data); + }) + .fail(function() { + moveOperation.reject(); + }); + return moveOperation.promise(); + }); + }; + /** * Deletes the specified xblock. * @param xblockInfo The model for the xblock to be deleted. @@ -267,6 +296,7 @@ define(['jquery', 'underscore', 'gettext', 'common/js/components/utils/view_util return { VisibilityState: VisibilityState, addXBlock: addXBlock, + moveXBlock: moveXBlock, duplicateXBlock: duplicateXBlock, deleteXBlock: deleteXBlock, updateXBlockField: updateXBlockField, diff --git a/common/static/common/js/components/views/feedback.js b/common/static/common/js/components/views/feedback.js index 901aaa61fa..4551d522fc 100644 --- a/common/static/common/js/components/views/feedback.js +++ b/common/static/common/js/components/views/feedback.js @@ -21,6 +21,8 @@ options: { title: '', message: '', + titleHtml: '', // an optional html that comes after the title. + messageHtml: '', // an optional html that comes after the message. intent: null, // "warning", "confirmation", "error", "announcement", "step-required", etc type: null, // "alert", "notification", or "prompt": set by subclass shown: true, // is this view currently being shown? diff --git a/common/static/common/js/components/views/feedback_move.js b/common/static/common/js/components/views/feedback_move.js new file mode 100644 index 0000000000..740521af1c --- /dev/null +++ b/common/static/common/js/components/views/feedback_move.js @@ -0,0 +1,49 @@ +/** + * The MovedAlertView to show confirmation message when moving XBlocks. + */ +(function(define) { + 'use strict'; + define(['jquery', 'underscore', 'common/js/components/views/feedback_alert', 'js/views/utils/xblock_utils', + 'js/views/utils/move_xblock_utils', 'edx-ui-toolkit/js/utils/string-utils'], + function($, _, AlertView, XBlockViewUtils, MoveXBlockUtils, StringUtils) { + var MovedAlertView = AlertView.Confirmation.extend({ + events: _.extend({}, AlertView.Confirmation.prototype.events, { + 'click .action-undo-move': 'undoMoveXBlock' + }), + + options: $.extend({}, AlertView.Confirmation.prototype.options), + + initialize: function() { + AlertView.prototype.initialize.apply(this, arguments); + this.movedAlertView = null; + }, + + undoMoveXBlock: function(event) { + var self = this, + $moveButton = $(event.target), + sourceLocator = $moveButton.data('source-locator'), + sourceDisplayName = $moveButton.data('source-display-name'), + sourceParentLocator = $moveButton.data('source-parent-locator'), + targetIndex = $moveButton.data('target-index'); + XBlockViewUtils.moveXBlock(sourceLocator, sourceParentLocator, targetIndex) + .done(function(response) { + // show XBlock element + $('.studio-xblock-wrapper[data-locator="' + response.move_source_locator + '"]').show(); + if (self.movedAlertView) { + self.movedAlertView.hide(); + } + self.movedAlertView = MoveXBlockUtils.showMovedNotification( + StringUtils.interpolate( + gettext('Move cancelled. "{sourceDisplayName}" has been moved back to its original ' + + 'location.'), + { + sourceDisplayName: sourceDisplayName + } + ) + ); + }); + } + }); + return MovedAlertView; + }); +}).call(this, define || RequireJS.define); diff --git a/common/static/common/templates/components/system-feedback.underscore b/common/static/common/templates/components/system-feedback.underscore index 9168abc9d5..625bc574a1 100644 --- a/common/static/common/templates/components/system-feedback.underscore +++ b/common/static/common/templates/components/system-feedback.underscore @@ -15,8 +15,9 @@ <% } %>
-

<%- title %>

+

<%- title %><% if(titleHtml) { %> <%= titleHtml %> <% } %>

<% if(obj.message) { %>

<%- message %>

<% } %> + <% if(messageHtml) { %> <%= messageHtml %> <% } %>
<% if(obj.actions) { %>