/* eslint-disable no-underscore-dangle */ /* globals Logger, interpolate */ (function() { 'use strict'; const { HtmlUtils } = window.edx; this.Sequence = (function() { function Sequence(element, runtime) { var self = this; this.removeBookmarkIconFromActiveNavItem = function(event) { return Sequence.prototype.removeBookmarkIconFromActiveNavItem.apply(self, [event]); }; this.addBookmarkIconToActiveNavItem = function(event) { return Sequence.prototype.addBookmarkIconToActiveNavItem.apply(self, [event]); }; this._change_sequential = function(direction, event) { return Sequence.prototype._change_sequential.apply(self, [direction, event]); }; this.selectPrevious = function(event) { return Sequence.prototype.selectPrevious.apply(self, [event]); }; this.selectNext = function(event) { return Sequence.prototype.selectNext.apply(self, [event]); }; this.goto = function(event) { return Sequence.prototype.goto.apply(self, [event]); }; this.toggleDropdown = function(event) { return Sequence.prototype.toggleDropdown.apply(self, [event]); }; this.toggleArrows = function() { return Sequence.prototype.toggleArrows.apply(self); }; this.addToUpdatedProblems = function(problemId, newContentState, newState) { return Sequence.prototype.addToUpdatedProblems.apply(self, [problemId, newContentState, newState]); }; this.hideTabTooltip = function(event) { return Sequence.prototype.hideTabTooltip.apply(self, [event]); }; this.displayTabTooltip = function(event) { return Sequence.prototype.displayTabTooltip.apply(self, [event]); }; this.renderDropdown = function() { return Sequence.prototype.renderDropdown.apply(self); } this.handleClickOutsideDropdown = function(event) { return Sequence.prototype.handleClickOutsideDropdown.apply(self, [event]); } this.arrowKeys = { LEFT: 37, UP: 38, RIGHT: 39, DOWN: 40 }; this.updatedProblems = {}; this.requestToken = $(element).data('request-token'); this.el = $(element).find('.sequence'); this.path = $('.path'); this.contents = this.$('.seq_contents'); this.content_container = this.$('#seq_content'); this.sr_container = this.$('.sr-is-focusable'); this.num_contents = this.contents.length; this.id = this.el.data('id'); this.getCompletionUrl = runtime.handlerUrl(element, 'get_completion'); this.gotoPositionUrl = runtime.handlerUrl(element, 'goto_position'); this.nextUrl = this.el.data('next-url'); this.prevUrl = this.el.data('prev-url'); this.savePosition = this.el.data('save-position'); this.showCompletion = this.el.data('show-completion'); this.keydownHandler($(element).find('#sequence-list .tab')); this.base_page_title = ($('title').data('base-title') || '').trim(); this.dropdownButtonTpl = _.template($('#dropdown-button-tpl').text())({}); this.renderDropdown(); this.bind(); this.render(parseInt(this.el.data('position'), 10)); } Sequence.prototype.renderDropdown = function() { // Renders the dropdown when there isn't enough space for all units in the bar // Hide the dropdown by default and only show if needed. this.$('#sequence-list > #dropdown-container').hide(); this.$(`#sequence-list > li.sequence-list-item`).show(); // Calculate the number of tabs that can fit comfortably and if the // number of units is greater we show the dropdown. const tabListWidth = this.$('#sequence-list').width(); const singleTabWidth = this.$('#sequence-list > li:first').width(); const tabCount = this.$('#sequence-list > li.sequence-list-item').length; const overFlowCount = Math.floor(tabListWidth / singleTabWidth); // Reduce 1 to offsets index and another one to accommodate the button const overFlowIdx = overFlowCount - 2; const showDropdown = overFlowCount < tabCount; if (!showDropdown) { return; } // If the dropdown button doesn't exist add it, otherwise move the // existing button to the correct place. if (this.$('#sequence-list > #dropdown-container').length === 0) { // xss-lint: disable=javascript-jquery-insertion this.$('#sequence-list > li.sequence-list-item').eq(overFlowIdx).after(this.dropdownButtonTpl); } else { this.$('#sequence-list > li.sequence-list-item').eq(overFlowIdx) // xss-lint: disable=javascript-jquery-insertion .after(this.$('#sequence-list > #dropdown-container')); } // Show the dropdown UX and hide all the overflowing unit buttons. this.$('#sequence-list > #dropdown-container').show(); this.$(`#sequence-list > li.sequence-list-item:lt(${overFlowIdx + 1})`).show(); this.$(`#sequence-list > li.sequence-list-item:gt(${overFlowIdx})`).hide(); const dropdownList = this.$('#dropdown-sequence-list > ol'); // The dropdown buttons are modified copies of the unit nav buttons. dropdownList.empty(); this.$(`#sequence-list > li.sequence-list-item:gt(${overFlowIdx})`).each((idx, el) => { const cloneEl = $(el).clone(); const navButton = cloneEl.find("button"); const unitTitle = navButton.data('page-title'); navButton.click(self.goto); navButton.find(".sequence-tooltip").remove(); navButton.find("span.icon").after( HtmlUtils.joinHtml(HtmlUtils.HTML(''), unitTitle, HtmlUtils.HTML('')).toString() ); //xss-lint: disable=javascript-jquery-insert-into-target cloneEl.show().appendTo(dropdownList); }); } Sequence.prototype.$ = function(selector) { return $(selector, this.el); }; Sequence.prototype.bind = function() { this.$('#sequence-list .nav-item').click(this.goto); $(document).click(this.handleClickOutsideDropdown); this.$('#dropdown-sequence-list .dropdown-item').click(this.goto); this.$('#dropdown-sequence-list-button').click(this.toggleDropdown); this.$('#sequence-list .nav-item').keypress(this.keyDownHandler); this.el.on('bookmark:add', this.addBookmarkIconToActiveNavItem); this.el.on('bookmark:remove', this.removeBookmarkIconFromActiveNavItem); this.$('#sequence-list .nav-item').on('focus mouseenter', this.displayTabTooltip); this.$('#sequence-list .nav-item').on('blur mouseleave', this.hideTabTooltip); $(window).on('resize', _.debounce(this.renderDropdown.bind(this), 200)); }; Sequence.prototype.handleClickOutsideDropdown = function(event) { if(!this.$('#dropdown-container')?.[0]?.contains(event.target)) { this.$('#dropdown-sequence-list').hide(); } } Sequence.prototype.toggleDropdown = function() { $('#dropdown-sequence-list').toggle(); } Sequence.prototype.previousNav = function(focused, index) { var $navItemList, $sequenceList = $(focused).parent().parent(); if (index === 0) { $navItemList = $sequenceList.find('li').last(); } else { $navItemList = $sequenceList.find('li:eq(' + index + ')').prev(); } $sequenceList.find('.tab').removeClass('visited').removeClass('focused'); $navItemList.find('.tab').addClass('focused').focus(); }; Sequence.prototype.nextNav = function(focused, index, total) { var $navItemList, $sequenceList = $(focused).parent().parent(); if (index === total) { $navItemList = $sequenceList.find('li').first(); } else { $navItemList = $sequenceList.find('li:eq(' + index + ')').next(); } $sequenceList.find('.tab').removeClass('visited').removeClass('focused'); $navItemList.find('.tab').addClass('focused').focus(); }; Sequence.prototype.keydownHandler = function(element) { var self = this; element.keydown(function(event) { var key = event.keyCode, $focused = $(event.currentTarget), $sequenceList = $focused.parent().parent(), index = $sequenceList.find('li') .index($focused.parent()), total = $sequenceList.find('li') .size() - 1; switch (key) { case self.arrowKeys.LEFT: event.preventDefault(); self.previousNav($focused, index); break; case self.arrowKeys.RIGHT: event.preventDefault(); self.nextNav($focused, index, total); break; // no default } }); }; Sequence.prototype.displayTabTooltip = function(event) { $(event.currentTarget).find('.sequence-tooltip').removeClass('sr'); }; Sequence.prototype.hideTabTooltip = function(event) { $(event.currentTarget).find('.sequence-tooltip').addClass('sr'); }; Sequence.prototype.updatePageTitle = function() { // update the page title to include the current section var currentUnitTitle, newPageTitle, positionLink = this.link_for(this.position); if (positionLink && positionLink.data('page-title')) { currentUnitTitle = positionLink.data('page-title'); newPageTitle = currentUnitTitle + ' | ' + this.base_page_title; if (newPageTitle !== document.title) { document.title = newPageTitle; } // Update the title section of the breadcrumb $('.nav-item-sequence').text(currentUnitTitle); } }; Sequence.prototype.hookUpContentStateChangeEvent = function() { var self = this; return $('.problems-wrapper').bind('contentChanged', function(event, problemId, newContentState, newState) { return self.addToUpdatedProblems(problemId, newContentState, newState); }); }; Sequence.prototype.addToUpdatedProblems = function(problemId, newContentState, newState) { /** * Used to keep updated problem's state temporarily. * params: * 'problem_id' is problem id. * 'new_content_state' is the updated content of the problem. * 'new_state' is the updated state of the problem. */ // initialize for the current sequence if there isn't any updated problem for this position. if (!this.anyUpdatedProblems(this.position)) { this.updatedProblems[this.position] = {}; } // Now, put problem content and score against problem id for current active sequence. this.updatedProblems[this.position][problemId] = [newContentState, newState]; }; Sequence.prototype.anyUpdatedProblems = function(position) { /** * check for the updated problems for given sequence position. * params: * 'position' can be any sequence position. */ return typeof this.updatedProblems[position] !== 'undefined'; }; Sequence.prototype.enableButton = function(buttonClass, buttonAction) { this.$(buttonClass) .removeClass('disabled') .removeAttr('disabled') .click(buttonAction); }; Sequence.prototype.disableButton = function(buttonClass) { this.$(buttonClass).addClass('disabled').attr('disabled', true); }; Sequence.prototype.updateButtonState = function(buttonClass, buttonAction, isAtBoundary, boundaryUrl) { if (isAtBoundary && boundaryUrl === 'None') { this.disableButton(buttonClass); } else { this.enableButton(buttonClass, buttonAction); } }; Sequence.prototype.toggleArrows = function() { var isFirstTab, isLastTab, nextButtonClass, previousButtonClass; this.$('.sequence-nav-button').unbind('click'); // previous button isFirstTab = this.position === 1; previousButtonClass = '.sequence-nav-button.button-previous'; this.updateButtonState(previousButtonClass, this.selectPrevious, isFirstTab, this.prevUrl); // next button // use inequality in case contents.length is 0 and position is 1. isLastTab = this.position >= this.contents.length; nextButtonClass = '.sequence-nav-button.button-next'; this.updateButtonState(nextButtonClass, this.selectNext, isLastTab, this.nextUrl); }; Sequence.prototype.render = function(newPosition) { var bookmarked, currentTab, sequenceLinks, self = this; if (this.position !== newPosition) { if (this.position) { this.mark_visited(this.position); if (this.showCompletion) { this.update_completion(this.position); } if (this.savePosition) { $.postWithPrefix(this.gotoPositionUrl, JSON.stringify({ position: newPosition })); } } // On Sequence change, fire custom event 'sequence:change' on element. // Added for aborting video bufferization, see ../video/10_main.js this.el.trigger('sequence:change'); this.mark_active(newPosition); currentTab = this.contents.eq(newPosition - 1); bookmarked = this.el.find('.active .bookmark-icon').hasClass('bookmarked'); // update the data-attributes with latest contents only for updated problems. this.content_container .html(currentTab.text()) // xss-lint: disable=javascript-jquery-html .attr('aria-labelledby', currentTab.attr('aria-labelledby')) .data('bookmarked', bookmarked); if (this.anyUpdatedProblems(newPosition)) { $.each(this.updatedProblems[newPosition], function(problemId, latestData) { var latestContent, latestResponse; latestContent = latestData[0]; latestResponse = latestData[1]; self.content_container .find("[data-problem-id='" + problemId + "']") .data('content', latestContent) .data('problem-score', latestResponse.current_score) .data('problem-total-possible', latestResponse.total_possible) .data('attempts-used', latestResponse.attempts_used); }); } XBlock.initializeBlocks(this.content_container, this.requestToken); // For embedded circuit simulator exercises in 6.002x if (window.hasOwnProperty('update_schematics')) { window.update_schematics(); } this.position = newPosition; this.toggleArrows(); this.hookUpContentStateChangeEvent(); this.updatePageTitle(); sequenceLinks = this.content_container.find('a.seqnav'); sequenceLinks.click(this.goto); this.sr_container.focus(); } }; Sequence.prototype.goto = function(event) { var alertTemplate, alertText, isBottomNav, newPosition, widgetPlacement; event.preventDefault(); this.$('#dropdown-sequence-list').hide(); // Links from courseware ..., was .target_tab if ($(event.currentTarget).hasClass('seqnav')) { newPosition = $(event.currentTarget).attr('href'); } else if ($(event.currentTarget).data('href') !== undefined) { location.href = $(event.currentTarget).data('href'); return true; // Tab links generated by backend template } else { newPosition = $(event.currentTarget).data('element'); } if ((newPosition >= 1) && (newPosition <= this.num_contents)) { isBottomNav = $(event.target).closest('nav[class="sequence-bottom"]').length > 0; if (isBottomNav) { widgetPlacement = 'bottom'; } else { widgetPlacement = 'top'; } // Formerly known as seq_goto Logger.log('edx.ui.lms.sequence.tab_selected', { current_tab: this.position, target_tab: newPosition, tab_count: this.num_contents, id: this.id, widget_placement: widgetPlacement }); // On Sequence change, destroy any existing polling thread // for queued submissions, see ../capa/display.js if (window.queuePollerID) { window.clearTimeout(window.queuePollerID); delete window.queuePollerID; } this.render(newPosition); } else { alertTemplate = gettext('Sequence error! Cannot navigate to %(tab_name)s in the current SequenceModule. Please contact the course staff.'); // eslint-disable-line max-len alertText = interpolate(alertTemplate, { tab_name: newPosition }, true); alert(alertText); // eslint-disable-line no-alert } }; Sequence.prototype.selectNext = function(event) { this._change_sequential('next', event); }; Sequence.prototype.selectPrevious = function(event) { this._change_sequential('previous', event); }; // `direction` can be 'previous' or 'next' Sequence.prototype._change_sequential = function(direction, event) { var analyticsEventName, isBottomNav, newPosition, offset, targetUrl, widgetPlacement; // silently abort if direction is invalid. if (direction !== 'previous' && direction !== 'next') { return; } event.preventDefault(); analyticsEventName = 'edx.ui.lms.sequence.' + direction + '_selected'; isBottomNav = $(event.target).closest('nav[class="sequence-bottom"]').length > 0; if (isBottomNav) { widgetPlacement = 'bottom'; } else { widgetPlacement = 'top'; } if ((direction === 'next') && (this.position >= this.contents.length)) { targetUrl = this.nextUrl; } else if ((direction === 'previous') && (this.position === 1 || this.contents.length === 0)) { targetUrl = this.prevUrl; } // Formerly known as seq_next and seq_prev Logger.log(analyticsEventName, { id: this.id, current_tab: this.position, tab_count: this.num_contents, widget_placement: widgetPlacement }).always(function() { if (targetUrl) { // Wait to load the new page until we've attempted to log the event window.location.href = targetUrl; } }); // If we're staying on the page, no need to wait for the event logging to finish if (!targetUrl) { // If the bottom nav is used, scroll to the top of the page on change. if (isBottomNav) { $.scrollTo(0, 150); } offset = { next: 1, previous: -1 }; newPosition = this.position + offset[direction]; this.render(newPosition); } }; Sequence.prototype.link_for = function(position) { return this.$('#sequence-list .nav-item[data-element=' + position + ']'); }; Sequence.prototype.mark_visited = function(position) { // Don't overwrite class attribute to avoid changing Progress class var element = this.link_for(position); element.attr({tabindex: '-1', 'aria-selected': 'false', 'aria-expanded': 'false'}) .removeClass('inactive') .removeClass('active') .removeClass('focused') .addClass('visited'); }; Sequence.prototype.update_completion = function(position) { var element = this.link_for(position); var usageKey = element[0].attributes['data-id'].value; var completionIndicators = element.find('.check-circle'); if (completionIndicators.length) { $.postWithPrefix(this.getCompletionUrl, JSON.stringify({ usage_key: usageKey }), function(data) { if (data.complete === true) { completionIndicators.removeClass('is-hidden'); } }); } // Reload progress bar this.$('#progress-frame').attr('src', this.$('#progress-frame').attr('src')); }; Sequence.prototype.mark_active = function(position) { // Don't overwrite class attribute to avoid changing Progress class var element = this.link_for(position); element.attr({tabindex: '0', 'aria-selected': 'true', 'aria-expanded': 'true'}) .removeClass('inactive') .removeClass('visited') .removeClass('focused') .addClass('active'); this.$('.sequence-list-wrapper').focus(); }; Sequence.prototype.addBookmarkIconToActiveNavItem = function(event) { event.preventDefault(); this.el.find('.nav-item.active .bookmark-icon').removeClass('is-hidden').addClass('bookmarked'); this.el.find('.nav-item.active .bookmark-icon-sr').text(gettext('Bookmarked')); }; Sequence.prototype.removeBookmarkIconFromActiveNavItem = function(event) { event.preventDefault(); this.el.find('.nav-item.active .bookmark-icon').removeClass('bookmarked').addClass('is-hidden'); this.el.find('.nav-item.active .bookmark-icon-sr').text(''); }; return Sequence; }()); }).call(this);