/** * The CourseOutlineView is used to render the contents of the course for the Course Outline page. * It is a recursive set of views, where each XBlock has its own instance, and each of the children * are shown as child CourseOutlineViews. * * This class extends XBlockOutlineView to add unique capabilities needed by the course outline: * - sections are initially expanded but subsections and other children are shown as collapsed * - changes cause a refresh of the entire section rather than just the view for the changed xblock * - adding units will automatically redirect to the unit page rather than showing them inline */ define(['jquery', 'underscore', 'js/views/xblock_outline', 'edx-ui-toolkit/js/utils/string-utils', 'common/js/components/utils/view_utils', 'js/views/utils/xblock_utils', 'js/models/xblock_outline_info', 'js/views/modals/course_outline_modals', 'js/utils/drag_and_drop', 'common/js/components/views/feedback_notification', 'common/js/components/views/feedback_prompt', 'js/views/utils/tagging_drawer_utils', 'js/views/tag_count', 'js/models/tag_count'], function( $, _, XBlockOutlineView, StringUtils, ViewUtils, XBlockViewUtils, XBlockOutlineInfo, CourseOutlineModalsFactory, ContentDragger, NotificationView, PromptView, TaggingDrawerUtils, TagCountView, TagCountModel ) { var CourseOutlineView = XBlockOutlineView.extend({ // takes XBlockOutlineInfo as a model templateName: 'course-outline', render: function() { var renderResult = XBlockOutlineView.prototype.render.call(this); this.makeContentDraggable(this.el); // Show/hide the paste button this.initializePasteButton(this.el); this.renderTagCount(); return renderResult; }, renderTagCount: function() { if (this.model.get('is_tagging_feature_disabled')) { return; // Tagging feature is disabled; don't initialize the tag count view. } const contentId = this.model.get('id'); // Skip the course block since that is handled elsewhere in course_manage_tags if (contentId.includes('@course')) { return; } const tagCountsByBlock = this.model.get('tag_counts_by_block'); const tagsCount = tagCountsByBlock !== undefined ? tagCountsByBlock[contentId] : 0; const tagCountElem = this.$(`.tag-count[data-locator="${contentId}"]`); var countModel = new TagCountModel({ content_id: contentId, tags_count: tagsCount, course_authoring_url: this.model.get('course_authoring_url'), }, {parse: true}); var tagCountView = new TagCountView({el: tagCountElem, model: countModel}); tagCountView.setupMessageListener(); tagCountView.render(); tagCountElem.click((event) => { event.preventDefault(); this.openManageTagsDrawer(); }); }, shouldExpandChildren: function() { return this.expandedLocators.contains(this.model.get('id')); }, shouldRenderChildren: function() { // Render all nodes up to verticals but not below return !this.model.isVertical(); }, getChildViewClass: function() { return CourseOutlineView; }, /** * Refresh the containing section (if there is one) or else refresh the entire course. * Note that the refresh will preserve the expanded state of this view and all of its * children. * @param viewState The desired initial state of the view, or null if none. * @returns {jQuery promise} A promise representing the refresh operation. */ refresh: function(viewState) { var getViewToRefresh, view, expandedLocators; // eslint-disable-next-line no-shadow getViewToRefresh = function(view) { if (view.model.isChapter() || !view.parentView) { return view; } return getViewToRefresh(view.parentView); }; view = getViewToRefresh(this); viewState = viewState || {}; view.initialState = viewState; return view.model.fetch({}); }, /** * Updates the collapse/expand state for this outline element, and then calls refresh. * @param isCollapsed true if the element should be collapsed, else false */ refreshWithCollapsedState: function(isCollapsed) { var locator = this.model.get('id'); if (isCollapsed) { this.expandedLocators.remove(locator); } else { this.expandedLocators.add(locator); } this.refresh(); }, onChildAdded: function(locator, category, event) { if (category === 'vertical') { // For units, redirect to the new unit's page in inline edit mode this.onUnitAdded(locator); } else if (category === 'chapter' && this.model.hasChildren()) { this.onSectionAdded(locator); } else { // For all other block types, refresh the view and do the following: // - show the new block expanded // - ensure it is scrolled into view // - make its name editable this.refresh(this.createNewItemViewState(locator, ViewUtils.getScrollOffset($(event.target)))); } }, /** * Perform specific actions for duplicated xblock. * @param {String} locator The locator of the new duplicated xblock. * @param {String} xblockType The front-end terminology of the xblock category. * @param {jquery Element} xblockElement The xblock element to be duplicated. */ onChildDuplicated: function(locator, xblockType, xblockElement) { var scrollOffset = ViewUtils.getScrollOffset(xblockElement); if (xblockType === 'section') { this.onSectionAdded(locator, xblockElement, scrollOffset); } else { // For all other block types, refresh the view and do the following: // - show the new block expanded // - ensure it is scrolled into view // - make its name editable this.refresh(this.createNewItemViewState(locator, scrollOffset)); } }, onSectionAdded: function(locator, xblockElement, scrollOffset) { var self = this, initialState = self.createNewItemViewState(locator, scrollOffset), sectionInfo, sectionView; // For new chapters in a non-empty view, add a new child view and render it // to avoid the expense of refreshing the entire page. if (this.model.hasChildren()) { sectionInfo = new XBlockOutlineInfo({ id: locator, category: 'chapter' }); // Fetch the full xblock info for the section and then create a view for it sectionInfo.fetch().done(function() { sectionView = self.createChildView(sectionInfo, self.model, {parentView: self}); sectionView.initialState = initialState; sectionView.expandedLocators = self.expandedLocators; sectionView.render(); self.addChildView(sectionView, xblockElement); sectionView.setViewState(initialState); }); } else { this.refresh(initialState); } }, onChildDeleted: function(childView) { var xblockInfo = this.model, children = xblockInfo.get('child_info') && xblockInfo.get('child_info').children; // If deleting a section that isn't the final one, just remove it for efficiency // as it cannot visually effect the other sections. if (childView.model.isChapter() && children && children.length > 1) { childView.$el.remove(); children.splice(children.indexOf(childView.model), 1); } else { this.refresh(); } }, createNewItemViewState: function(locator, scrollOffset) { this.expandedLocators.add(locator); return { locator_to_show: locator, edit_display_name: true, scroll_offset: scrollOffset || 0 }; }, editXBlock: function() { var modal; var enableProctoredExams = false; var enableTimedExams = false; var unitLevelDiscussions = false; if (this.model.get('category') === 'sequential') { if (this.parentView.parentView.model.has('enable_proctored_exams')) { enableProctoredExams = this.parentView.parentView.model.get('enable_proctored_exams'); } if (this.parentView.parentView.model.has('enable_timed_exams')) { enableTimedExams = this.parentView.parentView.model.get('enable_timed_exams'); } } if (this.model.get('category') === 'vertical') { unitLevelDiscussions = this.parentView.parentView.parentView.model.get('unit_level_discussions'); } modal = CourseOutlineModalsFactory.getModal('edit', this.model, { onSave: this.refresh.bind(this), parentInfo: this.parentInfo, enable_proctored_exams: enableProctoredExams, enable_timed_exams: enableTimedExams, unit_level_discussions: unitLevelDiscussions, xblockType: XBlockViewUtils.getXBlockType( this.model.get('category'), this.parentView.model, true ) }); if (modal) { modal.show(); } }, publishXBlock: function() { var modal = CourseOutlineModalsFactory.getModal('publish', this.model, { onSave: this.refresh.bind(this), xblockType: XBlockViewUtils.getXBlockType( this.model.get('category'), this.parentView.model, true ) }); if (modal) { modal.show(); } }, /** Copy a Unit to the clipboard */ copyXBlock() { const clipboardEndpoint = "/api/content-staging/v1/clipboard/"; // Start showing a "Copying" notification: ViewUtils.runOperationShowingMessage(gettext('Copying'), () => { return $.postJSON( clipboardEndpoint, { usage_key: this.model.get('id') } ).then((data) => { // const status = data.content?.status; const status = data.content && data.content.status; // ^ platform's old require.js/esprima breaks on newer syntax in some JS files but not all. if (status === "ready") { // The Unit has been copied and is ready to use. this.clipboardManager.updateUserClipboard(data); // This will update the UI and notify other tabs return data; } else if (status === "loading") { // The clipboard is being loaded asynchronously. // Poll the endpoint until the copying process is complete: const deferred = $.Deferred(); const checkStatus = () => { $.getJSON(clipboardEndpoint, (pollData) => { // const newStatus = pollData.content?.status; const newStatus = pollData.content && pollData.content.status; if (newStatus === "ready") { this.clipboardManager.updateUserClipboard(pollData); deferred.resolve(pollData); } else if (newStatus === "loading") { setTimeout(checkStatus, 1000); } else { deferred.reject(); throw new Error(`Unexpected clipboard status "${newStatus}" in successful API response.`); } }) } setTimeout(checkStatus, 1000); return deferred; } else { throw new Error(`Unexpected clipboard status "${status}" in successful API response.`); } }); }); }, initializePasteButton(element) { if ($(element).hasClass('outline-subsection')) { if (this.options.canEdit && this.clipboardManager) { // We should have the user's clipboard status from CourseOutlinePage, whose clipboardManager manages // the clipboard data on behalf of all the XBlocks in the outline. this.refreshPasteButton(this.clipboardManager.userClipboard); this.clipboardManager.addEventListener("update", (event) => { this.refreshPasteButton(event.detail); }); } else { this.$(".paste-component").hide(); } } }, /** * Given the latest information about the user's clipboard, hide or show the Paste button as appropriate. */ refreshPasteButton(data) { // 'data' is the same data returned by the "get clipboard status" API endpoint // i.e. /api/content-staging/v1/clipboard/ if (this.options.canEdit && data.content) { if (data.content.status === "expired") { // This has expired and can no longer be pasted. this.$(".paste-component").hide(); } else if (data.content.block_type === 'vertical') { // This is suitable for pasting as a unit. const detailsPopupEl = this.$(".clipboard-details-popup")[0]; // Only Units should have the paste button initialized if (detailsPopupEl !== undefined) { const detailsPopupEl = this.$(".clipboard-details-popup")[0]; detailsPopupEl.querySelector(".detail-block-name").innerText = data.content.display_name; detailsPopupEl.querySelector(".detail-block-type").innerText = data.content.block_type_display; detailsPopupEl.querySelector(".detail-course-name").innerText = data.source_context_title; if (data.source_edit_url) { detailsPopupEl.setAttribute("href", data.source_edit_url); detailsPopupEl.classList.remove("no-edit-link"); } else { detailsPopupEl.setAttribute("href", "#"); detailsPopupEl.classList.add("no-edit-link"); } this.$('.paste-component').show() } } else { this.$('.paste-component').hide() } } else { this.$('.paste-component').hide(); } }, createPlaceholderElementForPaste(category, componentDisplayName) { const nameStr = StringUtils.interpolate(gettext("Copy of '{componentDisplayName}'"), { componentDisplayName }, true); const el = document.createElement("li"); el.classList.add("outline-item", "outline-" + category, "has-warnings", "is-draggable"); el.innerHTML = `