feat: new actions menu for copy/pasting units in Studio (behind waffle flag) (#32891)
This commit is contained in:
@@ -214,6 +214,22 @@ ENABLE_COPY_PASTE_FEATURE = WaffleFlag(
|
||||
)
|
||||
|
||||
|
||||
# .. toggle_name: contentstore.enable_copy_paste_units
|
||||
# .. toggle_implementation: WaffleFlag
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: Moves most unit-level actions into a submenu and adds new "Copy Unit" and "Paste
|
||||
# Unit" actions which can be used to copy units within or among courses.
|
||||
# .. toggle_use_cases: temporary
|
||||
# .. toggle_creation_date: 2023-08-01
|
||||
# .. toggle_target_removal_date: 2023-10-01
|
||||
# .. toggle_tickets: https://github.com/openedx/modular-learning/issues/11 https://github.com/openedx/modular-learning/issues/50
|
||||
ENABLE_COPY_PASTE_UNITS = WaffleFlag(
|
||||
f'{CONTENTSTORE_NAMESPACE}.enable_copy_paste_units',
|
||||
__name__,
|
||||
CONTENTSTORE_LOG_PREFIX,
|
||||
)
|
||||
|
||||
|
||||
# .. toggle_name: contentstore.enable_studio_content_api
|
||||
# .. toggle_implementation: WaffleFlag
|
||||
# .. toggle_default: False
|
||||
|
||||
@@ -28,11 +28,7 @@ from common.djangoapps.xblock_django.models import XBlockStudioConfigurationFlag
|
||||
from cms.djangoapps.contentstore.toggles import use_new_problem_editor
|
||||
from openedx.core.lib.xblock_utils import get_aside_from_xblock, is_xblock_aside
|
||||
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration
|
||||
try:
|
||||
# Technically this is a django app plugin, so we should not error if it's not installed:
|
||||
import openedx.core.djangoapps.content_staging.api as content_staging_api
|
||||
except ImportError:
|
||||
content_staging_api = None
|
||||
from openedx.core.djangoapps.content_staging import api as content_staging_api
|
||||
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from ..toggles import use_new_unit_page
|
||||
@@ -198,10 +194,7 @@ def container_handler(request, usage_key_string):
|
||||
index += 1
|
||||
|
||||
# Get the status of the user's clipboard so they can paste components if they have something to paste
|
||||
if content_staging_api:
|
||||
user_clipboard = content_staging_api.get_user_clipboard_json(request.user.id, request)
|
||||
else:
|
||||
user_clipboard = {"content": None}
|
||||
user_clipboard = content_staging_api.get_user_clipboard_json(request.user.id, request)
|
||||
return render_to_response('container.html', {
|
||||
'language_code': request.LANGUAGE_CODE,
|
||||
'context_course': course, # Needed only for display of menus at top of page.
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""
|
||||
Views related to operations on course objects
|
||||
"""
|
||||
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
@@ -60,6 +58,7 @@ from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadReq
|
||||
from common.djangoapps.util.string_utils import _has_non_ascii_characters
|
||||
from common.djangoapps.xblock_django.api import deprecated_xblocks
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from openedx.core.djangoapps.content_staging import api as content_staging_api
|
||||
from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements
|
||||
from openedx.core.djangoapps.models.course_details import CourseDetails
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
@@ -747,6 +746,8 @@ def course_index(request, course_key):
|
||||
advanced_dict = CourseMetadata.fetch(course_block)
|
||||
proctoring_errors = CourseMetadata.validate_proctoring_settings(course_block, advanced_dict, request.user)
|
||||
|
||||
user_clipboard = content_staging_api.get_user_clipboard_json(request.user.id, request)
|
||||
|
||||
return render_to_response('course_outline.html', {
|
||||
'language_code': request.LANGUAGE_CODE,
|
||||
'context_course': course_block,
|
||||
@@ -754,6 +755,7 @@ def course_index(request, course_key):
|
||||
'sections': sections,
|
||||
'course_structure': course_structure,
|
||||
'initial_state': course_outline_initial_state(locator_to_show, course_structure) if locator_to_show else None, # lint-amnesty, pylint: disable=line-too-long
|
||||
'initial_user_clipboard': user_clipboard,
|
||||
'rerun_notification_id': current_action.id if current_action else None,
|
||||
'course_release_date': course_release_date,
|
||||
'settings_url': settings_url,
|
||||
|
||||
@@ -38,6 +38,7 @@ from xblock.core import XBlock
|
||||
from xblock.fields import Scope
|
||||
|
||||
from cms.djangoapps.contentstore.config.waffle import SHOW_REVIEW_RULES_FLAG
|
||||
from cms.djangoapps.contentstore.toggles import ENABLE_COPY_PASTE_UNITS
|
||||
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
|
||||
from common.djangoapps.edxmako.services import MakoService
|
||||
from common.djangoapps.static_replace import replace_static_urls
|
||||
@@ -1346,6 +1347,9 @@ def create_xblock_info( # lint-amnesty, pylint: disable=too-many-statements
|
||||
else:
|
||||
xblock_info["staff_only_message"] = False
|
||||
|
||||
# If the ENABLE_COPY_PASTE_UNITS feature flag is enabled, we show the newer menu that allows copying/pasting
|
||||
xblock_info["enable_copy_paste_units"] = ENABLE_COPY_PASTE_UNITS.is_enabled()
|
||||
|
||||
xblock_info[
|
||||
"has_partition_group_components"
|
||||
] = has_children_visible_to_specific_partition_groups(xblock)
|
||||
|
||||
@@ -3,12 +3,13 @@ define([
|
||||
], function(CourseOutlinePage, XBlockOutlineInfo) {
|
||||
'use strict';
|
||||
|
||||
return function(XBlockOutlineInfoJson, initialStateJson) {
|
||||
return function(XBlockOutlineInfoJson, initialStateJson, initialUserClipboardJson) {
|
||||
var courseXBlock = new XBlockOutlineInfo(XBlockOutlineInfoJson, {parse: true}),
|
||||
view = new CourseOutlinePage({
|
||||
el: $('#content'),
|
||||
model: courseXBlock,
|
||||
initialState: initialStateJson
|
||||
initialState: initialStateJson,
|
||||
initialUserClipboard: initialUserClipboardJson,
|
||||
});
|
||||
view.render();
|
||||
};
|
||||
|
||||
@@ -8,10 +8,11 @@
|
||||
* - 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', 'common/js/components/utils/view_utils', 'js/views/utils/xblock_utils',
|
||||
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'],
|
||||
function(
|
||||
$, _, XBlockOutlineView, ViewUtils, XBlockViewUtils,
|
||||
$, _, XBlockOutlineView, StringUtils, ViewUtils, XBlockViewUtils,
|
||||
XBlockOutlineInfo, CourseOutlineModalsFactory, ContentDragger
|
||||
) {
|
||||
var CourseOutlineView = XBlockOutlineView.extend({
|
||||
@@ -22,6 +23,8 @@ function(
|
||||
render: function() {
|
||||
var renderResult = XBlockOutlineView.prototype.render.call(this);
|
||||
this.makeContentDraggable(this.el);
|
||||
// Show/hide the paste button
|
||||
this.initializePasteButton(this.el);
|
||||
return renderResult;
|
||||
},
|
||||
|
||||
@@ -202,6 +205,198 @@ function(
|
||||
}
|
||||
},
|
||||
|
||||
/** 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 = `
|
||||
<div class="${category}-header">
|
||||
<h3 class="${category}-header-details" style="width: 50%">
|
||||
<span class="${category}-title item-title">
|
||||
${nameStr}
|
||||
</span>
|
||||
</h3>
|
||||
<div class="${category}-header-actions" style="width: 50%; text-align: right;">
|
||||
<span class="icon fa fa-spinner fa-pulse fa-spin" aria-hidden="true"></span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return $(el);
|
||||
},
|
||||
|
||||
/** The user has clicked on the "Paste Unit button" */
|
||||
pasteUnit(event) {
|
||||
// event.preventDefault();
|
||||
// Get the ID of the parent container (a subsection if we're pasting a unit/vertical) that we're pasting into
|
||||
const $parentElement = $(event.target).closest('.outline-item');
|
||||
const parentLocator = $parentElement.data('locator');
|
||||
// Get the display name of what we're pasting:
|
||||
const displayName = $(event.target).closest('.paste-component').find('.detail-block-name').text();
|
||||
// Create a placeholder XBlock while we're pasting:
|
||||
const $placeholderEl = this.createPlaceholderElementForPaste('unit', displayName);
|
||||
const $listPanel = $(event.target).closest('.outline-content').children('ol').first();
|
||||
$listPanel.append($placeholderEl);
|
||||
|
||||
// Start showing a "Pasting" notification:
|
||||
ViewUtils.runOperationShowingMessage(gettext('Pasting'), () => {
|
||||
return $.postJSON(this.model.urlRoot + '/', {
|
||||
parent_locator: parentLocator,
|
||||
staged_content: "clipboard",
|
||||
}).then((data) => {
|
||||
this.refresh(); // Update this and replace the placeholder with the actual pasted unit.
|
||||
return data;
|
||||
}).fail(() => {
|
||||
$placeholderEl.remove();
|
||||
});
|
||||
}).done((data) => {
|
||||
const {
|
||||
conflicting_files: conflictingFiles,
|
||||
error_files: errorFiles,
|
||||
new_files: newFiles,
|
||||
} = data.static_file_notices;
|
||||
|
||||
const notices = [];
|
||||
if (errorFiles.length) {
|
||||
notices.push((next) => new PromptView.Error({
|
||||
title: gettext("Some errors occurred"),
|
||||
message: (
|
||||
gettext("The following required files could not be added to the course:") +
|
||||
" " + errorFiles.join(", ")
|
||||
),
|
||||
actions: {primary: {text: gettext("OK"), click: (x) => { x.hide(); next(); }}},
|
||||
}));
|
||||
}
|
||||
if (conflictingFiles.length) {
|
||||
notices.push((next) => new PromptView.Warning({
|
||||
title: gettext("You may need to update a file(s) manually"),
|
||||
message: (
|
||||
gettext(
|
||||
"The following files already exist in this course but don't match the " +
|
||||
"version used by the component you pasted:"
|
||||
) + " " + conflictingFiles.join(", ")
|
||||
),
|
||||
actions: {primary: {text: gettext("OK"), click: (x) => { x.hide(); next(); }}},
|
||||
}));
|
||||
}
|
||||
if (newFiles.length) {
|
||||
notices.push(() => new NotificationView.Confirmation({
|
||||
title: gettext("New files were added to this course's Files & Uploads"),
|
||||
message: (
|
||||
gettext("The following required files were imported to this course:") +
|
||||
" " + newFiles.join(", ")
|
||||
),
|
||||
closeIcon: true,
|
||||
}));
|
||||
}
|
||||
if (notices.length) {
|
||||
// Show the notices, one at a time:
|
||||
const showNext = () => {
|
||||
const view = notices.shift()(showNext);
|
||||
view.show();
|
||||
}
|
||||
// Delay to avoid conflict with the "Pasting..." notification.
|
||||
setTimeout(showNext, 1250);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
highlightsXBlock: function() {
|
||||
var modal = CourseOutlineModalsFactory.getModal('highlights', this.model, {
|
||||
onSave: this.refresh.bind(this),
|
||||
@@ -216,6 +411,23 @@ function(
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* If the new "Actions" menu is enabled, most actions like Configure,
|
||||
* Duplicate, Move, Delete, etc. are moved into this menu. For this
|
||||
* event, we just toggle displaying the menu.
|
||||
* @param {*} event
|
||||
*/
|
||||
showActionsMenu(event) {
|
||||
const showActionsButton = event.currentTarget;
|
||||
const subMenu = showActionsButton.parentElement.querySelector(".wrapper-nav-sub");
|
||||
// Code in 'base.js' normally handles toggling these dropdowns but since this one is
|
||||
// not present yet during the domReady event, we have to handle displaying it ourselves.
|
||||
subMenu.classList.toggle("is-shown");
|
||||
// if propagation is not stopped, the event will bubble up to the
|
||||
// body element, which will close the dropdown.
|
||||
event.stopPropagation();
|
||||
},
|
||||
|
||||
addButtonActions: function(element) {
|
||||
XBlockOutlineView.prototype.addButtonActions.apply(this, arguments);
|
||||
element.find('.configure-button').click(function(event) {
|
||||
@@ -232,6 +444,17 @@ function(
|
||||
this.highlightsXBlock();
|
||||
}
|
||||
}.bind(this));
|
||||
element.find('.copy-button').click((event) => {
|
||||
event.preventDefault();
|
||||
this.copyXBlock();
|
||||
});
|
||||
element.find('.paste-component-button').click((event) => {
|
||||
event.preventDefault();
|
||||
this.pasteUnit(event);
|
||||
});
|
||||
element.find('.action-actions-menu').click((event) => {
|
||||
this.showActionsMenu(event);
|
||||
});
|
||||
},
|
||||
|
||||
makeContentDraggable: function(element) {
|
||||
|
||||
@@ -13,6 +13,43 @@ function($, _, gettext, BasePage, XBlockViewUtils, CourseOutlineView, ViewUtils,
|
||||
|
||||
var expandedLocators, CourseOutlinePage;
|
||||
|
||||
/**
|
||||
* On the course outline page, many different UI elements (for now, every unit on the page) need to know the status
|
||||
* of the user's clipboard. This singleton manages the state of the user's clipboard and can emit events whenever
|
||||
* the clipboard is changed, whether from another tab or some action the user took on this page.
|
||||
*/
|
||||
class ClipboardManager extends EventTarget {
|
||||
constructor(initialUserClipboard) {
|
||||
super();
|
||||
this._userClipboard = initialUserClipboard;
|
||||
// Refresh the status when something is copied on another tab:
|
||||
this.clipboardBroadcastChannel = new BroadcastChannel("studio_clipboard_channel");
|
||||
this.clipboardBroadcastChannel.onmessage = (event) => {
|
||||
this.updateUserClipboard(event.data, false);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data about the user's clipboard. This is exactly the same as
|
||||
* what would be returned from the "get clipboard" REST API.
|
||||
*/
|
||||
get userClipboard() {
|
||||
return this._userClipboard;
|
||||
}
|
||||
|
||||
updateUserClipboard(newUserClipboard, broadcast = true) {
|
||||
this._userClipboard = newUserClipboard;
|
||||
// Emit an "updated" event so listeners can subscribe. This is different than the broadcast channel
|
||||
// because this only works within the DOM of a single tab, not across all open tabs that the user has.
|
||||
// In other words, this event trickles down to each section, subsection, and unit view on the outline page.
|
||||
this.dispatchEvent(new CustomEvent("update", {detail: newUserClipboard}));
|
||||
// But also notify listeners on other tabs:
|
||||
if (broadcast) {
|
||||
this.clipboardBroadcastChannel.postMessage(newUserClipboard); // And notify any other open tabs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CourseOutlinePage = BasePage.extend({
|
||||
// takes XBlockInfo as a model
|
||||
|
||||
@@ -33,7 +70,8 @@ function($, _, gettext, BasePage, XBlockViewUtils, CourseOutlineView, ViewUtils,
|
||||
pollingDelay: 100,
|
||||
|
||||
options: {
|
||||
collapsedClass: 'is-collapsed'
|
||||
collapsedClass: 'is-collapsed',
|
||||
initialUserClipboard: {content: null},
|
||||
},
|
||||
|
||||
// Extracting this to a variable allows comprehensive themes to replace or extend `CourseOutlineView`.
|
||||
@@ -53,6 +91,7 @@ function($, _, gettext, BasePage, XBlockViewUtils, CourseOutlineView, ViewUtils,
|
||||
$('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function() {
|
||||
$('.wrapper-alert-announcement').removeClass('is-shown').addClass('is-hidden');
|
||||
}));
|
||||
this.clipboardManager = new ClipboardManager(this.options.initialUserClipboard);
|
||||
},
|
||||
|
||||
setCollapseExpandVisibility: function() {
|
||||
@@ -110,7 +149,8 @@ function($, _, gettext, BasePage, XBlockViewUtils, CourseOutlineView, ViewUtils,
|
||||
model: this.model,
|
||||
isRoot: true,
|
||||
initialState: this.initialState,
|
||||
expandedLocators: this.expandedLocators
|
||||
expandedLocators: this.expandedLocators,
|
||||
clipboardManager: this.clipboardManager,
|
||||
});
|
||||
this.outlineView.render();
|
||||
this.outlineView.setViewState(this.initialState || {});
|
||||
|
||||
@@ -23,7 +23,8 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, XBlockStringFieldE
|
||||
// takes XBlockInfo as a model
|
||||
|
||||
options: {
|
||||
collapsedClass: 'is-collapsed'
|
||||
collapsedClass: 'is-collapsed',
|
||||
canEdit: true, // If not specified, assume user has permission to make changes
|
||||
},
|
||||
|
||||
templateName: 'xblock-outline',
|
||||
@@ -40,6 +41,7 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, XBlockStringFieldE
|
||||
this.parentView = this.options.parentView;
|
||||
this.renderedChildren = false;
|
||||
this.model.on('sync', this.onSync, this);
|
||||
this.clipboardManager = this.options.clipboardManager; // May be undefined if not on the course outline page
|
||||
},
|
||||
|
||||
render: function() {
|
||||
@@ -110,7 +112,8 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, XBlockStringFieldE
|
||||
includesChildren: this.shouldRenderChildren(),
|
||||
hasExplicitStaffLock: this.model.get('has_explicit_staff_lock'),
|
||||
staffOnlyMessage: this.model.get('staff_only_message'),
|
||||
course: course
|
||||
course: course,
|
||||
enableCopyPasteUnits: this.model.get("enable_copy_paste_units"), // ENABLE_COPY_PASTE_UNITS waffle flag
|
||||
};
|
||||
},
|
||||
|
||||
@@ -218,7 +221,8 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, XBlockStringFieldE
|
||||
parentView: this,
|
||||
initialState: this.initialState,
|
||||
expandedLocators: this.expandedLocators,
|
||||
template: this.template
|
||||
template: this.template,
|
||||
clipboardManager: this.clipboardManager,
|
||||
}, options));
|
||||
},
|
||||
|
||||
|
||||
@@ -320,7 +320,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
// New "Paste component" menu, shown on the Unit page to users with a component in their clipboard
|
||||
// New "Paste component" menu, shown on the Unit and outline page to users with a component in their clipboard
|
||||
.paste-component {
|
||||
margin: $baseline ($baseline/2);
|
||||
|
||||
@@ -454,6 +454,18 @@ $outline-indent-width: $baseline;
|
||||
}
|
||||
}
|
||||
|
||||
// The "Paste new [Unit/Section/Subsection]" button on the course outline page, visible only when clipboard has the right thing in it
|
||||
.outline .paste-component {
|
||||
margin: ($baseline/2) 0; // different margins for the "paste new unit/section/subsection" button on the outline vs. the unit page
|
||||
|
||||
.paste-component-button {
|
||||
// Also there is a smaller font size for this button on the outline vs. the unit page:
|
||||
&.button {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
%outline-item-status {
|
||||
@extend %t-copy-sub2;
|
||||
@@ -902,3 +914,22 @@ $outline-indent-width: $baseline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The "actions menu" for subsections, sections, and units on the Studio course outline page
|
||||
.outline .actions-list.nav-dd .wrapper-nav-sub {
|
||||
|
||||
@include text-align(left); // Undo the 'text-align: right' inherited from the parent
|
||||
|
||||
z-index: 10; // stay in front of other components on the page.
|
||||
|
||||
.nav-item {
|
||||
a {
|
||||
// Match styling of ".wrapper-header nav .nav-item a" (dropdowns in Studio header)
|
||||
color: $gray-d1;
|
||||
|
||||
&:hover {
|
||||
color: $uxpl-blue-hover-active;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ from django.urls import reverse
|
||||
require(["js/factories/outline"], function (OutlineFactory) {
|
||||
OutlineFactory(
|
||||
${course_structure | n, dump_js_escaped_json},
|
||||
${initial_state | n, dump_js_escaped_json}
|
||||
${initial_state | n, dump_js_escaped_json},
|
||||
${initial_user_clipboard | n, dump_js_escaped_json}
|
||||
);
|
||||
});
|
||||
</%block>
|
||||
|
||||
@@ -143,7 +143,7 @@ if (is_proctored_exam) {
|
||||
<% } else { %>
|
||||
<div class="<%- xblockType %>-header-actions">
|
||||
<% } %>
|
||||
<ul class="actions-list">
|
||||
<ul class="actions-list nav-dd ui-right">
|
||||
<% var discussion_settings = course.get('discussions_settings') %>
|
||||
<% if ((xblockInfo.isVertical()) && discussion_settings && (!parentInfo.get('is_time_limited'))) { %>
|
||||
<% if (xblockInfo.get('discussion_enabled') && (discussion_settings.provider_type == "openedx")) { %>
|
||||
@@ -169,34 +169,74 @@ if (is_proctored_exam) {
|
||||
</a>
|
||||
</li>
|
||||
<% } %>
|
||||
<% if (xblockInfo.isEditableOnCourseOutline()) { %>
|
||||
<li class="action-item action-configure">
|
||||
<a href="#" data-tooltip="<%- gettext('Configure') %>" class="configure-button action-button">
|
||||
<span class="icon fa fa-gear" aria-hidden="true"></span>
|
||||
<span class="sr action-button-text"><%- gettext('Configure') %></span>
|
||||
</a>
|
||||
</li>
|
||||
<% } %>
|
||||
<% if (xblockInfo.isDuplicable()) { %>
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" data-tooltip="<%- gettext('Duplicate') %>" class="duplicate-button action-button">
|
||||
<span class="icon fa fa-copy" aria-hidden="true"></span>
|
||||
<span class="sr action-button-text"><%- gettext('Duplicate') %></span>
|
||||
</a>
|
||||
</li>
|
||||
<% } %>
|
||||
<% if (xblockInfo.isDeletable()) { %>
|
||||
<li class="action-item action-delete">
|
||||
<a href="#" data-tooltip="<%- gettext('Delete') %>" class="delete-button action-button">
|
||||
<span class="icon fa fa-trash-o" aria-hidden="true"></span>
|
||||
<span class="sr action-button-text"><%- gettext('Delete') %></span>
|
||||
</a>
|
||||
<% if (enableCopyPasteUnits) { %>
|
||||
<!--
|
||||
If the ENABLE_COPY_PASTE_UNITS feature flag is enabled, all these actions (besides "Publish")
|
||||
appear in a menu. We use .nav-dd on the parent element and .nav-item on this button to get the
|
||||
same dropdown menu appearance and behavior as in Studio's various other nav bars.
|
||||
-->
|
||||
<li class="action-item action-actions-menu nav-item">
|
||||
<button data-tooltip="<%- gettext('Actions') %>" class="btn-default show-actions-menu-button action-button">
|
||||
<span class="icon fa fa-ellipsis-v" aria-hidden="true"></span>
|
||||
<span class="sr"><%- gettext('Actions') %></span>
|
||||
</button>
|
||||
<div class="wrapper wrapper-nav-sub" style="right: -10px; top: 45px;">
|
||||
<div class="nav-sub">
|
||||
<ul>
|
||||
<% if (xblockInfo.isEditableOnCourseOutline()) { %>
|
||||
<li class="nav-item">
|
||||
<a class="configure-button" href="#" role="button"><%- gettext('Configure') %></a>
|
||||
</li>
|
||||
<% } %>
|
||||
<% if (xblockInfo.isVertical()) { %>
|
||||
<li class="nav-item">
|
||||
<a class="copy-button" href="#" role="button"><%- gettext('Copy to Clipboard') %></a>
|
||||
</li>
|
||||
<% } %>
|
||||
<% if (xblockInfo.isDuplicable()) { %>
|
||||
<li class="nav-item">
|
||||
<a class="duplicate-button" href="#" role="button"><%- gettext('Duplicate') %></a>
|
||||
</li>
|
||||
<% } %>
|
||||
<% if (xblockInfo.isDeletable()) { %>
|
||||
<li class="nav-item">
|
||||
<a class="delete-button" href="#" role="button"><%- gettext('Delete') %></a>
|
||||
</li>
|
||||
<% } %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<% } else { %>
|
||||
<% if (xblockInfo.isEditableOnCourseOutline()) { %>
|
||||
<li class="action-item action-configure">
|
||||
<a href="#" data-tooltip="<%- gettext('Configure') %>" class="configure-button action-button">
|
||||
<span class="icon fa fa-gear" aria-hidden="true"></span>
|
||||
<span class="sr action-button-text"><%- gettext('Configure') %></span>
|
||||
</a>
|
||||
</li>
|
||||
<% } %>
|
||||
<% if (xblockInfo.isDuplicable()) { %>
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" data-tooltip="<%- gettext('Duplicate') %>" class="duplicate-button action-button">
|
||||
<span class="icon fa fa-copy" aria-hidden="true"></span>
|
||||
<span class="sr action-button-text"><%- gettext('Duplicate') %></span>
|
||||
</a>
|
||||
</li>
|
||||
<% } %>
|
||||
<% if (xblockInfo.isDeletable()) { %>
|
||||
<li class="action-item action-delete">
|
||||
<a href="#" data-tooltip="<%- gettext('Delete') %>" class="delete-button action-button">
|
||||
<span class="icon fa fa-trash-o" aria-hidden="true"></span>
|
||||
<span class="sr action-button-text"><%- gettext('Delete') %></span>
|
||||
</a>
|
||||
</li>
|
||||
<% } %>
|
||||
<% } %>
|
||||
<% if (xblockInfo.isDraggable()) { %>
|
||||
<li class="action-item action-drag">
|
||||
<span data-tooltip="<%- gettext('Drag to reorder') %>"
|
||||
class="drag-handle <%- xblockType %>-drag-handle action">
|
||||
class="drag-handle <%- xblockType %>-drag-handle action">
|
||||
<span class="sr"><%- gettext('Drag to reorder') %></span>
|
||||
</span>
|
||||
</li>
|
||||
@@ -410,8 +450,38 @@ if (is_proctored_exam) {
|
||||
>
|
||||
<span class="icon fa fa-plus" aria-hidden="true"></span><%- addChildLabel %>
|
||||
</a>
|
||||
|
||||
<!--
|
||||
Technically this isn't pasting a "component" (leaf XBlock), but we re-use most of the same UI
|
||||
elements and CSS from the Unit page's "paste component" button, so the class names are the same.
|
||||
-->
|
||||
<div
|
||||
class="paste-component"
|
||||
style="display: none;"
|
||||
data-category="<%- childCategory %>"
|
||||
data-parent="<%- xblockInfo.get('id') %>"
|
||||
>
|
||||
<button type="button" class="button paste-component-button">
|
||||
<span class="icon fa fa-paste" aria-hidden="true"></span>
|
||||
<%- interpolate(
|
||||
gettext('Paste %(xblock_type)s'), { xblock_type: defaultNewChildName }, true
|
||||
) %>
|
||||
</button>
|
||||
<div class="paste-component-whats-in-clipboard" tabindex="0">
|
||||
<!-- These details get filled in by JavaScript code when it makes the paste button visible: -->
|
||||
<a href="#" class="clipboard-details-popup" onClick="if (this.getAttribute('href') === '#') return false;" target="_blank">
|
||||
<span class="fa fa-external-link" aria-hidden="true"></span>
|
||||
<strong class="detail-block-name">Block Name</strong>
|
||||
<span class="detail-block-type">Type</span>
|
||||
<%- gettext("From:") %> <span class="detail-course-name">Course Name Goes Here</span>
|
||||
</a>
|
||||
<span class="icon fa fa-question-circle" aria-hidden="true"></span>
|
||||
<%- gettext("What's in my clipboard?") %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<% } %>
|
||||
<% } %>
|
||||
<% } %>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
Reference in New Issue
Block a user