define(["domReady", "jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notification", "draggabilly", "js/utils/cancel_on_escape", "js/utils/get_date", "js/utils/module"], function (domReady, $, ui, _, gettext, NotificationView, Draggabilly, CancelOnEscape, DateUtils, ModuleUtils) { var modalSelector = '.edit-section-publish-settings'; var toggleSections = function(e) { e.preventDefault(); var $section = $('.courseware-section'); var $button = $(this); var $labelCollapsed = $('<i class="icon-arrow-up"></i> <span class="label">' + gettext('Collapse All Sections') + '</span>'); var $labelExpanded = $('<i class="icon-arrow-down"></i> <span class="label">' + gettext('Expand All Sections') + '</span>'); var buttonLabel = $button.hasClass('is-activated') ? $labelCollapsed : $labelExpanded; $button.toggleClass('is-activated').html(buttonLabel); if ($button.hasClass('is-activated')) { $section.addClass('collapsed'); // first child in order to avoid the icons on the subsection lists which are not in the first child $section.find('header .expand-collapse').removeClass('collapse').addClass('expand'); } else { $section.removeClass('collapsed'); // first child in order to avoid the icons on the subsection lists which are not in the first child $section.find('header .expand-collapse').removeClass('expand').addClass('collapse'); } }; var toggleSubmodules = function(e) { e.preventDefault(); $(this).toggleClass('expand').toggleClass('collapse'); $(this).closest('.is-collapsible, .window').toggleClass('collapsed'); }; var closeModalNew = function () { $('body').removeClass('dialog-is-shown'); $('.edit-section-publish-settings').removeClass('is-shown'); }; var editSectionPublishDate = function (e) { e.preventDefault(); var $modal = $(modalSelector); $modal.attr('data-locator', $(this).attr('data-locator')); $modal.find('.start-date').val($(this).attr('data-date')); $modal.find('.start-time').val($(this).attr('data-time')); if ($modal.find('.start-date').val() == '' && $modal.find('.start-time').val() == '') { $modal.find('.save-button').hide(); } $modal.find('.section-name').html('"' + $(this).closest('.courseware-section').find('.section-name-span').text() + '"'); $('body').addClass('dialog-is-shown'); $('.edit-section-publish-settings').addClass('is-shown'); }; var saveSetSectionScheduleDate = function (e) { e.preventDefault(); var datetime = DateUtils( $('.edit-section-publish-settings .start-date'), $('.edit-section-publish-settings .start-time') ); var locator = $(modalSelector).attr('data-locator'); analytics.track('Edited Section Release Date', { 'course': course_location_analytics, 'id': locator, 'start': datetime }); var saving = new NotificationView.Mini({ title: gettext("Saving…") }); saving.show(); // call into server to commit the new order $.ajax({ url: ModuleUtils.getUpdateUrl(locator), type: "PUT", dataType: "json", contentType: "application/json", data: JSON.stringify({ 'metadata': { 'start': datetime } }) }).success(function() { var pad2 = function(number) { // pad a number to two places: useful for formatting months, days, hours, etc // when displaying a date/time return (number < 10 ? '0' : '') + number; }; var $thisSection = $('.courseware-section[data-locator="' + locator + '"]'); var html = _.template( '<span class="published-status">' + '<strong>' + gettext("Release date:") + ' </strong>' + gettext("{month}/{day}/{year} at {hour}:{minute} UTC") + '</span>' + '<a href="#" class="edit-release-date action" data-date="{month}/{day}/{year}" data-time="{hour}:{minute}" data-locator="{locator}"><i class="icon-time"></i> <span class="sr">' + gettext("Edit section release date") + '</span></a>', {year: datetime.getUTCFullYear(), month: pad2(datetime.getUTCMonth() + 1), day: pad2(datetime.getUTCDate()), hour: pad2(datetime.getUTCHours()), minute: pad2(datetime.getUTCMinutes()), locator: locator}, {interpolate: /\{(.+?)\}/g}); $thisSection.find('.section-published-date').html(html); saving.hide(); closeModalNew(); }); }; var addNewSection = function (e) { e.preventDefault(); $(e.target).addClass('disabled'); var $newSection = $($('#new-section-template').html()); var $cancelButton = $newSection.find('.new-section-name-cancel'); $('.courseware-overview').prepend($newSection); $newSection.find('.new-section-name').focus().select(); $newSection.find('.section-name-form').bind('submit', saveNewSection); $cancelButton.bind('click', cancelNewSection); CancelOnEscape($cancelButton); }; var saveNewSection = function (e) { e.preventDefault(); var $saveButton = $(this).find('.new-section-name-save'); var parent = $saveButton.data('parent'); var category = $saveButton.data('category'); var display_name = $(this).find('.new-section-name').val(); analytics.track('Created a Section', { 'course': course_location_analytics, 'display_name': display_name }); $.postJSON(ModuleUtils.getUpdateUrl(), { 'parent_locator': parent, 'category': category, 'display_name': display_name }, function(data) { if (data.locator != undefined) location.reload(); }); }; var cancelNewSection = function (e) { e.preventDefault(); $('.new-courseware-section-button').removeClass('disabled'); $(this).parents('section.new-section').remove(); }; var addNewSubsection = function (e) { e.preventDefault(); var $section = $(this).closest('.courseware-section'); var $newSubsection = $($('#new-subsection-template').html()); $section.find('.subsection-list > ol').append($newSubsection); $section.find('.new-subsection-name-input').focus().select(); var $saveButton = $newSubsection.find('.new-subsection-name-save'); var $cancelButton = $newSubsection.find('.new-subsection-name-cancel'); var parent = $(this).parents("section.courseware-section").data("locator"); $saveButton.data('parent', parent); $saveButton.data('category', $(this).data('category')); $newSubsection.find('.new-subsection-form').bind('submit', saveNewSubsection); $cancelButton.bind('click', cancelNewSubsection); CancelOnEscape($cancelButton); }; var saveNewSubsection = function (e) { e.preventDefault(); var parent = $(this).find('.new-subsection-name-save').data('parent'); var category = $(this).find('.new-subsection-name-save').data('category'); var display_name = $(this).find('.new-subsection-name-input').val(); analytics.track('Created a Subsection', { 'course': course_location_analytics, 'display_name': display_name }); $.postJSON(ModuleUtils.getUpdateUrl(), { 'parent_locator': parent, 'category': category, 'display_name': display_name }, function(data) { if (data.locator != undefined) { location.reload(); } }); }; var cancelNewSubsection = function (e) { e.preventDefault(); $(this).parents('li.courseware-subsection').remove(); }; var overviewDragger = { droppableClasses: 'drop-target drop-target-prepend drop-target-before drop-target-after', validDropClass: "valid-drop", expandOnDropClass: "expand-on-drop", /* * Determine information about where to drop the currently dragged * element. Returns the element to attach to and the method of * attachment ('before', 'after', or 'prepend'). */ findDestination: function (ele, yChange) { var eleY = ele.offset().top; var eleYEnd = eleY + ele.height(); var containers = $(ele.data('droppable-class')); for (var i = 0; i < containers.length; i++) { var container = $(containers[i]); // Exclude the 'new unit' buttons, and make sure we don't // prepend an element to itself var siblings = container.children().filter(function () { return $(this).data('locator') !== undefined && !$(this).is(ele); }); // If the container is collapsed, check to see if the // element is on top of its parent list -- don't check the // position of the container var parentList = container.parents(ele.data('parent-location-selector')).first(); if (parentList.hasClass('collapsed')) { var parentListTop = parentList.offset().top; // To make it easier to drop subsections into collapsed sections (which have // a lot of visual padding around them), allow a fudge factor around the // parent element. var collapseFudge = 10; if (Math.abs(eleY - parentListTop) < collapseFudge || (eleY > parentListTop && eleYEnd - collapseFudge <= parentListTop + parentList.height()) ) { return { ele: container, attachMethod: 'prepend', parentList: parentList }; } } // Otherwise, do check the container else { // If the list is empty, we should prepend to it, // unless both elements are at the same location -- // this prevents the user from being unable to expand // a section var containerY = container.offset().top; if (siblings.length == 0 && containerY != eleY && Math.abs(eleY - containerY) < 50) { return { ele: container, attachMethod: 'prepend' }; } // Otherwise the list is populated, and we should attach before/after a sibling else { for (var j = 0; j < siblings.length; j++) { var $sibling = $(siblings[j]); var siblingY = $sibling.offset().top; var siblingHeight = $sibling.height(); var siblingYEnd = siblingY + siblingHeight; // Facilitate dropping into the beginning or end of a list // (coming from opposite direction) via a "fudge factor". Math.min is for Jasmine test. var fudge = Math.min(Math.ceil(siblingHeight / 2), 20); // Dragging to top or bottom of a list with only one element is tricky // because the element being dragged may be the same size as the sibling. if (siblings.length == 1) { // Element being dragged is within the drop target. Use the direction // of the drag (yChange) to determine before or after. if (eleY + fudge >= siblingY && eleYEnd - fudge <= siblingYEnd) { return { ele: $sibling, attachMethod: yChange > 0 ? 'after' : 'before' }; } // Element being dragged is before the drop target. else if (Math.abs(eleYEnd - siblingY) <= fudge) { return { ele: $sibling, attachMethod: 'before' }; } // Element being dragged is after the drop target. else if (Math.abs(eleY - siblingYEnd) <= fudge) { return { ele: $sibling, attachMethod: 'after' }; } } else { // Dragging up into end of list. if (j == siblings.length - 1 && yChange < 0 && Math.abs(eleY - siblingYEnd) <= fudge) { return { ele: $sibling, attachMethod: 'after' }; } // Dragging up or down into beginning of list. else if (j == 0 && Math.abs(eleY - siblingY) <= fudge) { return { ele: $sibling, attachMethod: 'before' }; } // Dragging down into end of list. Special handling required because // the element being dragged may be taller then the element being dragged over // (if eleY can never be >= siblingY, general case at the end does not work). else if (j == siblings.length - 1 && yChange > 0 && Math.abs(eleYEnd - siblingYEnd) <= fudge) { return { ele: $sibling, attachMethod: 'after' }; } else if (eleY >= siblingY && eleY <= siblingYEnd) { return { ele: $sibling, attachMethod: eleY - siblingY <= siblingHeight / 2 ? 'before' : 'after' }; } } } } } } // Failed drag return { ele: null, attachMethod: '' } }, // Information about the current drag. dragState: {}, onDragStart: function (draggie, event, pointer) { var ele = $(draggie.element); this.dragState = { // Which element will be dropped into/onto on success dropDestination: null, // How we attach to the destination: 'before', 'after', 'prepend' attachMethod: '', // If dragging to an empty section, the parent section parentList: null, // The y location of the last dragMove event (to determine direction). lastY: 0, // The direction the drag is moving in (negative means up, positive down). dragDirection: 0 }; if (!ele.hasClass('collapsed')) { ele.addClass('collapsed'); ele.find('.expand-collapse').first().addClass('expand').removeClass('collapse'); // onDragStart gets called again after the collapse, so we can't just store a variable in the dragState. ele.addClass(this.expandOnDropClass); } }, onDragMove: function (draggie, event, pointer) { // Handle scrolling of the browser. var scrollAmount = 0; var dragBuffer = 10; if (window.innerHeight - dragBuffer < pointer.clientY) { scrollAmount = dragBuffer; } else if (dragBuffer > pointer.clientY) { scrollAmount = -(dragBuffer); } if (scrollAmount !== 0) { window.scrollBy(0, scrollAmount); return; } var yChange = draggie.dragPoint.y - this.dragState.lastY; if (yChange !== 0) { this.dragState.direction = yChange; } this.dragState.lastY = draggie.dragPoint.y; var ele = $(draggie.element); var destinationInfo = this.findDestination(ele, this.dragState.direction); var destinationEle = destinationInfo.ele; this.dragState.parentList = destinationInfo.parentList; // Clear out the old destination if (this.dragState.dropDestination) { this.dragState.dropDestination.removeClass(this.droppableClasses); } // Mark the new destination if (destinationEle && this.pointerInBounds(pointer, ele)) { ele.addClass(this.validDropClass); destinationEle.addClass('drop-target drop-target-' + destinationInfo.attachMethod); this.dragState.attachMethod = destinationInfo.attachMethod; this.dragState.dropDestination = destinationEle; } else { ele.removeClass(this.validDropClass); this.dragState.attachMethod = ''; this.dragState.dropDestination = null; } }, onDragEnd: function (draggie, event, pointer) { var ele = $(draggie.element); var destination = this.dragState.dropDestination; // Clear dragging state in preparation for the next event. if (destination) { destination.removeClass(this.droppableClasses); } ele.removeClass(this.validDropClass); // If the drag succeeded, rearrange the DOM and send the result. if (destination && this.pointerInBounds(pointer, ele)) { // Make sure we don't drop into a collapsed element if (this.dragState.parentList) { this.expandElement(this.dragState.parentList); } var method = this.dragState.attachMethod; destination[method](ele); this.handleReorder(ele); } // If the drag failed, send it back else { $('.was-dragging').removeClass('was-dragging'); ele.addClass('was-dragging'); } if (ele.hasClass(this.expandOnDropClass)) { this.expandElement(ele); ele.removeClass(this.expandOnDropClass); } // Everything in its right place ele.css({ top: 'auto', left: 'auto' }); this.dragState = {}; }, pointerInBounds: function (pointer, ele) { return pointer.clientX >= ele.offset().left && pointer.clientX < ele.offset().left + ele.width(); }, expandElement: function (ele) { ele.removeClass('collapsed'); ele.find('.expand-collapse').first().removeClass('expand').addClass('collapse'); }, /* * Find all parent-child changes and save them. */ handleReorder: function (ele) { var parentSelector = ele.data('parent-location-selector'); var childrenSelector = ele.data('child-selector'); var newParentEle = ele.parents(parentSelector).first(); var newParentLocator = newParentEle.data('locator'); var oldParentLocator = ele.data('parent'); // If the parent has changed, update the children of the old parent. if (newParentLocator !== oldParentLocator) { // Find the old parent element. var oldParentEle = $(parentSelector).filter(function () { return $(this).data('locator') === oldParentLocator; }); this.saveItem(oldParentEle, childrenSelector, function () { ele.data('parent', newParentLocator); }); } var saving = new NotificationView.Mini({ title: gettext('Saving…') }); saving.show(); ele.addClass('was-dropped'); // Timeout interval has to match what is in the CSS. setTimeout(function () { ele.removeClass('was-dropped'); }, 1000); this.saveItem(newParentEle, childrenSelector, function () { saving.hide(); }); }, /* * Actually save the update to the server. Takes the element * representing the parent item to save, a CSS selector to find * its children, and a success callback. */ saveItem: function (ele, childrenSelector, success) { // Find all current child IDs. var children = _.map( ele.find(childrenSelector), function (child) { return $(child).data('locator'); } ); $.ajax({ url: ModuleUtils.getUpdateUrl(ele.data('locator')), type: 'PUT', dataType: 'json', contentType: 'application/json', data: JSON.stringify({ children: children }), success: success }); }, /* * Make `type` draggable using `handleClass`, able to be dropped * into `droppableClass`, and with parent type * `parentLocationSelector`. */ makeDraggable: function (type, handleClass, droppableClass, parentLocationSelector) { _.each( $(type), function (ele) { // Remember data necessary to reconstruct the parent-child relationships $(ele).data('droppable-class', droppableClass); $(ele).data('parent-location-selector', parentLocationSelector); $(ele).data('child-selector', type); var draggable = new Draggabilly(ele, { handle: handleClass, containment: '.wrapper-dnd' }); draggable.on('dragStart', _.bind(overviewDragger.onDragStart, overviewDragger)); draggable.on('dragMove', _.bind(overviewDragger.onDragMove, overviewDragger)); draggable.on('dragEnd', _.bind(overviewDragger.onDragEnd, overviewDragger)); } ); } }; domReady(function() { // toggling overview section details $(function() { if ($('.courseware-section').length > 0) { $('.toggle-button-sections').addClass('is-shown'); } }); $('.toggle-button-sections').bind('click', toggleSections); $('.expand-collapse').bind('click', toggleSubmodules); var $body = $('body'); $body.on('click', '.section-published-date .edit-release-date', editSectionPublishDate); $body.on('click', '.edit-section-publish-settings .action-save', saveSetSectionScheduleDate); $body.on('click', '.edit-section-publish-settings .action-cancel', closeModalNew); $('.new-courseware-section-button').bind('click', addNewSection); $('.new-subsection-item').bind('click', addNewSubsection); // Section overviewDragger.makeDraggable( '.courseware-section', '.section-drag-handle', '.courseware-overview', 'article.courseware-overview' ); // Subsection overviewDragger.makeDraggable( '.id-holder', '.subsection-drag-handle', '.subsection-list > ol', '.courseware-section' ); // Unit overviewDragger.makeDraggable( '.unit', '.unit-drag-handle', 'ol.sortable-unit-list', 'li.courseware-subsection, article.subsection-body' ); }); return { overviewDragger: overviewDragger, saveSetSectionScheduleDate: saveSetSectionScheduleDate }; });