import $ from 'jquery'; import _ from 'underscore'; import AjaxHelpers from 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers'; import EditHelpers from 'js/spec_helpers/edit_helpers'; import TemplateHelpers from 'common/js/spec_helpers/template_helpers'; import ViewHelpers from 'common/js/spec_helpers/view_helpers'; import MoveXBlockModal from 'js/views/modals/move_xblock_modal'; import ContainerPage from 'js/views/pages/container'; import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils'; import StringUtils from 'edx-ui-toolkit/js/utils/string-utils'; import XBlockInfo from 'js/models/xblock_info'; import Course from 'js/models/course'; import 'mock-ajax'; describe('MoveXBlock', function() { 'use strict'; var modal, showModal, renderViews, createXBlockInfo, createCourseOutline, courseOutlineOptions, parentChildMap, categoryMap, createChildXBlockInfo, xblockAncestorInfo, courseOutline, verifyBreadcrumbViewInfo, verifyListViewInfo, getDisplayedInfo, clickForwardButton, clickBreadcrumbButton, verifyXBlockInfo, nextCategory, verifyMoveEnabled, getSentRequests, verifyNotificationStatus, sendMoveXBlockRequest, moveXBlockWithSuccess, getMovedAlertNotification, verifyConfirmationFeedbackTitleText, verifyConfirmationFeedbackRedirectLinkText, verifyUndoConfirmationFeedbackTitleText, verifyConfirmationFeedbackUndoMoveActionText, sourceParentXBlockInfo, mockContainerPage, createContainerPage, containerPage, sourceDisplayName = 'component_display_name_0', sourceLocator = 'component_ID_0', sourceParentLocator = 'unit_ID_0'; parentChildMap = { course: 'section', section: 'subsection', subsection: 'unit', unit: 'component' }; categoryMap = { section: 'chapter', subsection: 'sequential', unit: 'vertical', component: 'component' }; courseOutlineOptions = { section: 2, subsection: 2, unit: 2, component: 2 }; xblockAncestorInfo = { ancestors: [ { category: 'vertical', display_name: 'unit_display_name_0', id: 'unit_ID_0' }, { category: 'sequential', display_name: 'subsection_display_name_0', id: 'subsection_ID_0' }, { category: 'chapter', display_name: 'section_display_name_0', id: 'section_ID_0' }, { category: 'course', display_name: 'Demo Course', id: 'COURSE_ID_101' } ] }; sourceParentXBlockInfo = new XBlockInfo({ id: sourceParentLocator, display_name: 'unit_display_name_0', category: 'vertical' }); createContainerPage = function() { containerPage = new ContainerPage({ model: sourceParentXBlockInfo, templates: EditHelpers.mockComponentTemplates, el: $('#content'), isUnitPage: true }); }; beforeEach(function() { setFixtures("
"); mockContainerPage = readFixtures('templates/mock/mock-container-page.underscore'); TemplateHelpers.installTemplates([ 'basic-modal', 'modal-button', 'move-xblock-modal' ]); appendSetFixtures(mockContainerPage); window.course = new Course({ id: "5", name: "Course Name", url_name: "course_name", org: "course_org", num: "course_num", revision: "course_rev" }); createContainerPage(); courseOutline = createCourseOutline(courseOutlineOptions); showModal(); }); afterEach(function() { modal.hide(); courseOutline = null; containerPage.remove(); delete window.course; }); showModal = function() { modal = new MoveXBlockModal({ sourceXBlockInfo: new XBlockInfo({ id: sourceLocator, display_name: sourceDisplayName, category: 'component' }), sourceParentXBlockInfo: sourceParentXBlockInfo, XBlockUrlRoot: '/xblock' }); modal.show(); }; /** * 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_' + xblockIndex }; 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 courseXBlockInfo = { category: 'course', display_name: 'Demo Course', id: 'COURSE_ID_101' }; return createXBlockInfo('section', outlineOptions, courseXBlockInfo); }; /** * Render breadcrumb and XBlock list view. * * @param {any} courseOutlineInfo course outline info * @param {any} ancestorInfo ancestors info */ renderViews = function(courseOutlineInfo, ancestorInfo) { var ancestorInfo = ancestorInfo || {ancestors: []}; // eslint-disable-line no-redeclare modal.renderViews(courseOutlineInfo, ancestorInfo); }; /** * Extract displayed XBlock list info. * * @returns {Object} */ getDisplayedInfo = function() { var viewEl = modal.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(modal.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('(Currently selected)'); } } else { if (hasCurrentLocation) { expect(displayedInfo.currentLocationText).toEqual('(Current location)'); } expect(displayedInfo.forwardButtonSRTexts).toEqual( _.map(_.range(expectedXBlocksCount), function() { return 'View child items'; }) ); 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 = modal.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 modal.moveXBlockListView.$el.find('[data-item-index="' + buttonIndex + '"] button').click(); }; /** * Click on last clickable breadcrumb button. */ clickBreadcrumbButton = function() { modal.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); verifyMoveEnabled(category, hasCurrentLocation); 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); }; /** * Verify move button is enabled. * * @param {String} category XBlock category * @param {String} hasCurrentLocation do we need to check current location */ verifyMoveEnabled = function(category, hasCurrentLocation) { var isMoveEnabled = !modal.$el.find('.modal-actions .action-move').hasClass('is-disabled'); if (category === 'component' && !hasCurrentLocation) { expect(isMoveEnabled).toBeTruthy(); } else { expect(isMoveEnabled).toBeFalsy(); } }; /** * Verify notification status. * * @param {Object} requests requests object * @param {Object} notificationSpy notification spy * @param {String} notificationText notification text to be verified * @param {Integer} sourceIndex source index of the xblock */ 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); }; /** * Get move alert confirmation message HTML */ getMovedAlertNotification = function() { return $('#page-alert'); }; /** * Send move xblock request. * * @param {Object} requests requests object * @param {Object} xblockLocator Xblock id location * @param {Integer} targetIndex target index of the xblock * @param {Integer} sourceIndex source index of the xblock */ sendMoveXBlockRequest = function(requests, xblockLocator, targetIndex, sourceIndex) { var responseData, expectedData, sourceIndex = sourceIndex || 0; // eslint-disable-line no-redeclare responseData = expectedData = { move_source_locator: xblockLocator, parent_locator: modal.targetParentXBlockInfo.id }; 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 })); }; /** * Move xblock with success. * * @param {Object} requests requests object */ moveXBlockWithSuccess = function(requests) { // select a target item and click renderViews(courseOutline); _.each(_.range(3), function() { clickForwardButton(1); }); modal.$el.find('.modal-actions .action-move').click(); sendMoveXBlockRequest(requests, sourceLocator); AjaxHelpers.expectJsonRequest(requests, 'GET', '/xblock/' + sourceParentLocator); AjaxHelpers.respondWithJson(requests, sourceParentXBlockInfo); expect(getMovedAlertNotification().html().length).not.toEqual(0); verifyConfirmationFeedbackTitleText(sourceDisplayName); verifyConfirmationFeedbackRedirectLinkText(); verifyConfirmationFeedbackUndoMoveActionText(); }; /** * Verify success banner message html has correct title text. * * @param {String} displayName XBlock display name */ verifyConfirmationFeedbackTitleText = function(displayName) { expect(getMovedAlertNotification().find('.title').html() .trim()) .toEqual(StringUtils.interpolate('Success! "{displayName}" has been moved.', { displayName: displayName }) ); }; /** * Verify undo success banner message html has correct title text. * * @param {String} displayName XBlock display name */ verifyUndoConfirmationFeedbackTitleText = function(displayName) { expect(getMovedAlertNotification().find('.title').html()).toEqual( StringUtils.interpolate( 'Move cancelled. "{sourceDisplayName}" has been moved back to its original location.', { sourceDisplayName: displayName } ) ); }; /** * Verify success banner message html has correct redirect link text. */ verifyConfirmationFeedbackRedirectLinkText = function() { expect(getMovedAlertNotification().find('.nav-actions .action-secondary').html()) .toEqual('Take me to the new location'); }; /** * Verify success banner message html has correct undo move text. */ verifyConfirmationFeedbackUndoMoveActionText = function() { expect(getMovedAlertNotification().find('.nav-actions .action-primary').html()).toEqual('Undo move'); }; /** * Get sent requests. * * @returns {Object} */ getSentRequests = function() { return jasmine.Ajax.requests.filter(function(request) { return request.readyState > 0; }); }; it('renders views with correct information', function() { var outlineOptions = {section: 1, subsection: 1, unit: 1, component: 1}, outline = createCourseOutline(outlineOptions); renderViews(outline, xblockAncestorInfo); verifyXBlockInfo(outlineOptions, 'section', 0, 'forward', true); verifyXBlockInfo(outlineOptions, 'component', 0, 'backward', true); }); 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') { modal.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); renderViews(outline, xblockAncestorInfo); verifyXBlockInfo(outlineOptions, 'section', 0, 'forward', true); // click the outline breadcrumb to render sections modal.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(modal.moveXBlockListView.$el.find('.xblock-no-child-message').text().trim()) .toEqual(info.message); modal.moveXBlockListView.undelegateEvents(); modal.moveXBlockBreadcrumbView.undelegateEvents(); }); }); describe('Move button', function() { it('is disabled when navigating to same parent', function() { // select a target parent as the same as source parent and click renderViews(courseOutline); _.each(_.range(3), function() { clickForwardButton(0); }); verifyMoveEnabled('component', true); }); it('is enabled when navigating to different parent', function() { // select a target parent as the different as source parent and click renderViews(courseOutline); _.each(_.range(3), function() { clickForwardButton(1); }); verifyMoveEnabled('component', false); }); it('verify move state while navigating', function() { renderViews(courseOutline, xblockAncestorInfo); verifyXBlockInfo(courseOutlineOptions, 'section', 0, 'forward', true); // start from course outline again modal.moveXBlockBreadcrumbView.$el.find('.bc-container button').first().click(); verifyXBlockInfo(courseOutlineOptions, 'section', 1, 'forward', false); }); it('is disbabled when navigating to same source xblock', function() { var outline, libraryContentXBlockInfo = { category: 'library_content', display_name: 'Library Content', has_children: true, id: 'LIBRARY_CONTENT_ID' }, outlineOptions = {library_content: 1, component: 1}; // make above xblock source xblock. modal.sourceXBlockInfo = libraryContentXBlockInfo; outline = createXBlockInfo('component', outlineOptions, libraryContentXBlockInfo); renderViews(outline); expect(modal.$el.find('.modal-actions .action-move').hasClass('is-disabled')).toBeTruthy(); // select a target parent clickForwardButton(0); expect(modal.$el.find('.modal-actions .action-move').hasClass('is-disabled')).toBeTruthy(); }); it('is disabled when navigating inside source content experiment', function() { var outline, splitTestXBlockInfo = { category: 'split_test', display_name: 'Content Experiment', has_children: true, id: 'SPLIT_TEST_ID' }, outlineOptions = {split_test: 1, unit: 2, component: 1}; // make above xblock source xblock. modal.sourceXBlockInfo = splitTestXBlockInfo; outline = createXBlockInfo('unit', outlineOptions, splitTestXBlockInfo); renderViews(outline); expect(modal.$el.find('.modal-actions .action-move').hasClass('is-disabled')).toBeTruthy(); // navigate to groups level clickForwardButton(0); expect(modal.$el.find('.modal-actions .action-move').hasClass('is-disabled')).toBeTruthy(); // navigate to component level inside a group clickForwardButton(0); // move should be disabled because we are navigating inside source xblock expect(modal.$el.find('.modal-actions .action-move').hasClass('is-disabled')).toBeTruthy(); }); it('is disabled when navigating to any content experiment groups', function() { var outline, splitTestXBlockInfo = { category: 'split_test', display_name: 'Content Experiment', has_children: true, id: 'SPLIT_TEST_ID' }, outlineOptions = {split_test: 1, unit: 2, component: 1}; // group level should be disabled but component level inside groups should be movable outline = createXBlockInfo('unit', outlineOptions, splitTestXBlockInfo); renderViews(outline); // move is disabled on groups level expect(modal.$el.find('.modal-actions .action-move').hasClass('is-disabled')).toBeTruthy(); // navigate to component level inside a group clickForwardButton(1); expect(modal.$el.find('.modal-actions .action-move').hasClass('is-disabled')).toBeFalsy(); }); it('is enabled when navigating to any parentable component', function() { var parentableXBlockInfo = { category: 'vertical', display_name: 'Parentable Component', has_children: true, id: 'PARENTABLE_ID' }; renderViews(parentableXBlockInfo); // move is enabled on parentable xblocks. expect(modal.$el.find('.modal-actions .action-move').hasClass('is-disabled')).toBeFalsy(); }); it('is enabled when moving a component inside a parentable component', function() { // create a source parent with has_childern set true modal.sourceParentXBlockInfo = new XBlockInfo({ category: 'conditional', display_name: 'Parentable Component', has_children: true, id: 'PARENTABLE_ID' }); // navigate and verify move button is enabled renderViews(courseOutline); _.each(_.range(3), function() { clickForwardButton(0); }); // move is enabled when moving a component. expect(modal.$el.find('.modal-actions .action-move').hasClass('is-disabled')).toBeFalsy(); }); it('is disabled when navigating to any non-parentable component', function() { var nonParentableXBlockInfo = { category: 'html', display_name: 'Non Parentable Component', has_children: false, id: 'NON_PARENTABLE_ID' }; renderViews(nonParentableXBlockInfo); // move is disabled on non-parent xblocks. expect(modal.$el.find('.modal-actions .action-move').hasClass('is-disabled')).toBeTruthy(); }); }); describe('Move an xblock', function() { it('can not move in a disabled state', function() { verifyMoveEnabled(false); modal.$el.find('.modal-actions .action-move').click(); expect(getMovedAlertNotification().html().length).toEqual(0); expect(getSentRequests().length).toEqual(0); }); it('move an xblock when move button is clicked', function() { var requests = AjaxHelpers.requests(this); moveXBlockWithSuccess(requests); }); it('do not move an xblock when cancel button is clicked', function() { modal.$el.find('.modal-actions .action-cancel').click(); expect(getMovedAlertNotification().html().length).toEqual(0); expect(getSentRequests().length).toEqual(0); }); it('undo move an xblock when undo move link is clicked', function() { var sourceIndex = 0, requests = AjaxHelpers.requests(this); moveXBlockWithSuccess(requests); getMovedAlertNotification().find('.action-save').click(); AjaxHelpers.respondWithJson(requests, { move_source_locator: sourceLocator, parent_locator: sourceParentLocator, target_index: sourceIndex }); verifyUndoConfirmationFeedbackTitleText(sourceDisplayName); }); }); describe('shows a notification', function() { it('mini operation message when moving an xblock', function() { var requests = AjaxHelpers.requests(this), notificationSpy = ViewHelpers.createNotificationSpy(); // navigate to a target parent and click renderViews(courseOutline); _.each(_.range(3), function() { clickForwardButton(1); }); modal.$el.find('.modal-actions .action-move').click(); verifyNotificationStatus(requests, notificationSpy, 'Moving'); }); it('mini operation message when undo moving an xblock', function() { var notificationSpy, requests = AjaxHelpers.requests(this); moveXBlockWithSuccess(requests); notificationSpy = ViewHelpers.createNotificationSpy(); getMovedAlertNotification().find('.action-save').click(); verifyNotificationStatus(requests, notificationSpy, 'Undo moving'); }); it('error message when move request fails', function() { var requests = AjaxHelpers.requests(this), notificationSpy = ViewHelpers.createNotificationSpy('Error'); // select a target item and click renderViews(courseOutline); _.each(_.range(3), function() { clickForwardButton(1); }); modal.$el.find('.modal-actions .action-move').click(); AjaxHelpers.respondWithError(requests); ViewHelpers.verifyNotificationShowing(notificationSpy, "Studio's having trouble saving your work"); }); it('error message when undo move request fails', function() { var requests = AjaxHelpers.requests(this), notificationSpy = ViewHelpers.createNotificationSpy('Error'); moveXBlockWithSuccess(requests); getMovedAlertNotification().find('.action-save').click(); AjaxHelpers.respondWithError(requests); ViewHelpers.verifyNotificationShowing(notificationSpy, "Studio's having trouble saving your work"); }); }); });