feat: adds Library Content (v2) button to Studio Unit page (#35670)
Requires that v2 libraries are enabled.
This commit is contained in:
@@ -190,6 +190,8 @@ def xblock_type_display_name(xblock, default_display_name=None):
|
||||
# description like "Multiple Choice Problem", but that won't work if our 'block' argument is just the block_type
|
||||
# string ("problem").
|
||||
return _('Problem')
|
||||
elif category == 'library_v2':
|
||||
return _('Library Content')
|
||||
component_class = XBlock.load_class(category)
|
||||
if hasattr(component_class, 'display_name') and component_class.display_name.default:
|
||||
return _(component_class.display_name.default) # lint-amnesty, pylint: disable=translation-of-non-string
|
||||
|
||||
@@ -89,6 +89,7 @@ class ContainerHandlerSerializer(serializers.Serializer):
|
||||
unit_block_id = serializers.CharField(source="unit.location.block_id")
|
||||
subsection_location = serializers.CharField(source="subsection.location")
|
||||
course_sequence_ids = serializers.ListField(child=serializers.CharField())
|
||||
library_content_picker_url = serializers.CharField()
|
||||
|
||||
def get_assets_url(self, obj):
|
||||
"""
|
||||
|
||||
@@ -131,11 +131,13 @@ class DownstreamView(DeveloperErrorViewMixin, APIView):
|
||||
# Note that, if this fails and we raise a 4XX, then we will not call modulstore().update_item,
|
||||
# thus preserving the former value of `downstream.upstream`.
|
||||
downstream.upstream = new_upstream_ref
|
||||
sync_param = request.data.get("sync", "false").lower()
|
||||
if sync_param not in ["true", "false"]:
|
||||
sync_param = request.data.get("sync", "false")
|
||||
if isinstance(sync_param, str):
|
||||
sync_param = sync_param.lower()
|
||||
if sync_param not in ["true", "false", True, False]:
|
||||
raise ValidationError({"sync": "must be 'true' or 'false'"})
|
||||
try:
|
||||
if sync_param == "true":
|
||||
if sync_param == "true" or sync_param is True:
|
||||
sync_from_upstream(downstream=downstream, user=request.user)
|
||||
else:
|
||||
# Even if we're not syncing (i.e., updating the downstream's values with the upstream's), we still need
|
||||
|
||||
@@ -38,7 +38,28 @@ from cms.djangoapps.contentstore.toggles import (
|
||||
exam_setting_view_enabled,
|
||||
libraries_v1_enabled,
|
||||
libraries_v2_enabled,
|
||||
split_library_view_on_dashboard,
|
||||
use_new_advanced_settings_page,
|
||||
use_new_course_outline_page,
|
||||
use_new_certificates_page,
|
||||
use_new_export_page,
|
||||
use_new_files_uploads_page,
|
||||
use_new_grading_page,
|
||||
use_new_group_configurations_page,
|
||||
use_new_course_team_page,
|
||||
use_new_home_page,
|
||||
use_new_import_page,
|
||||
use_new_schedule_details_page,
|
||||
use_new_text_editor,
|
||||
use_new_textbooks_page,
|
||||
use_new_unit_page,
|
||||
use_new_updates_page,
|
||||
use_new_video_editor,
|
||||
use_new_video_uploads_page,
|
||||
use_new_custom_pages,
|
||||
)
|
||||
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
|
||||
from cms.djangoapps.models.settings.course_metadata import CourseMetadata
|
||||
from common.djangoapps.course_action_state.models import CourseRerunUIStateManager, CourseRerunState
|
||||
from common.djangoapps.course_action_state.managers import CourseActionStateItemNotFoundError
|
||||
from common.djangoapps.course_modes.models import CourseMode
|
||||
@@ -79,29 +100,6 @@ from openedx.core.lib.html_to_text import html_to_text
|
||||
from openedx.features.content_type_gating.models import ContentTypeGatingConfig
|
||||
from openedx.features.content_type_gating.partitions import CONTENT_TYPE_GATING_SCHEME
|
||||
from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML
|
||||
from cms.djangoapps.contentstore.toggles import (
|
||||
split_library_view_on_dashboard,
|
||||
use_new_advanced_settings_page,
|
||||
use_new_course_outline_page,
|
||||
use_new_certificates_page,
|
||||
use_new_export_page,
|
||||
use_new_files_uploads_page,
|
||||
use_new_grading_page,
|
||||
use_new_group_configurations_page,
|
||||
use_new_course_team_page,
|
||||
use_new_home_page,
|
||||
use_new_import_page,
|
||||
use_new_schedule_details_page,
|
||||
use_new_text_editor,
|
||||
use_new_textbooks_page,
|
||||
use_new_unit_page,
|
||||
use_new_updates_page,
|
||||
use_new_video_editor,
|
||||
use_new_video_uploads_page,
|
||||
use_new_custom_pages,
|
||||
)
|
||||
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
|
||||
from cms.djangoapps.models.settings.course_metadata import CourseMetadata
|
||||
from xmodule.library_tools import LegacyLibraryToolsService
|
||||
from xmodule.course_block import DEFAULT_START_DATE # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.data import CertificatesDisplayBehaviors
|
||||
@@ -431,6 +429,18 @@ def get_course_outline_url(course_locator) -> str:
|
||||
return course_outline_url
|
||||
|
||||
|
||||
def get_library_content_picker_url(course_locator) -> str:
|
||||
"""
|
||||
Gets course authoring microfrontend library content picker URL for the given parent block.
|
||||
"""
|
||||
content_picker_url = None
|
||||
if libraries_v2_enabled():
|
||||
mfe_base_url = get_course_authoring_url(course_locator)
|
||||
content_picker_url = f'{mfe_base_url}/component-picker'
|
||||
|
||||
return content_picker_url
|
||||
|
||||
|
||||
def get_unit_url(course_locator, unit_locator) -> str:
|
||||
"""
|
||||
Gets course authoring microfrontend URL for unit page view.
|
||||
@@ -2045,6 +2055,7 @@ def get_container_handler_context(request, usage_key, course, xblock): # pylint
|
||||
'user_clipboard': user_clipboard,
|
||||
'is_fullwidth_content': is_library_xblock,
|
||||
'course_sequence_ids': course_sequence_ids,
|
||||
'library_content_picker_url': get_library_content_picker_url(course.id),
|
||||
}
|
||||
return context
|
||||
|
||||
|
||||
@@ -135,6 +135,8 @@ def xblock_handler(request, usage_key_string=None):
|
||||
if duplicate_source_locator is not present
|
||||
:staged_content: use "clipboard" to paste from the OLX user's clipboard. (Incompatible with all other
|
||||
fields except parent_locator)
|
||||
:library_content_key: the key of the library content to add. (Incompatible with
|
||||
all other fields except parent_locator)
|
||||
The locator (unicode representation of a UsageKey) for the created xblock (minus children) is returned.
|
||||
"""
|
||||
return handle_xblock(request, usage_key_string)
|
||||
|
||||
@@ -26,7 +26,7 @@ from common.djangoapps.student.auth import has_course_author_access
|
||||
from common.djangoapps.xblock_django.api import authorable_xblocks, disabled_xblocks
|
||||
from common.djangoapps.xblock_django.models import XBlockStudioConfigurationFlag
|
||||
from cms.djangoapps.contentstore.helpers import is_unit
|
||||
from cms.djangoapps.contentstore.toggles import use_new_problem_editor, use_new_unit_page
|
||||
from cms.djangoapps.contentstore.toggles import libraries_v2_enabled, use_new_problem_editor, use_new_unit_page
|
||||
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import load_services_for_studio
|
||||
from openedx.core.lib.xblock_utils import get_aside_from_xblock, is_xblock_aside
|
||||
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration
|
||||
@@ -43,7 +43,16 @@ __all__ = [
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# NOTE: This list is disjoint from ADVANCED_COMPONENT_TYPES
|
||||
COMPONENT_TYPES = ['discussion', 'library', 'html', 'openassessment', 'problem', 'video', 'drag-and-drop-v2']
|
||||
COMPONENT_TYPES = [
|
||||
'discussion',
|
||||
'library',
|
||||
'library_v2', # Not an XBlock
|
||||
'html',
|
||||
'openassessment',
|
||||
'problem',
|
||||
'video',
|
||||
'drag-and-drop-v2',
|
||||
]
|
||||
|
||||
ADVANCED_COMPONENT_TYPES = sorted({name for name, class_ in XBlock.load_classes()} - set(COMPONENT_TYPES))
|
||||
|
||||
@@ -97,6 +106,10 @@ def _load_mixed_class(category):
|
||||
"""
|
||||
Load an XBlock by category name, and apply all defined mixins
|
||||
"""
|
||||
# Libraries v2 content doesn't have an XBlock.
|
||||
if category == 'library_v2':
|
||||
return None
|
||||
|
||||
component_class = XBlock.load_class(category)
|
||||
mixologist = Mixologist(settings.XBLOCK_MIXINS)
|
||||
return mixologist.mix(component_class)
|
||||
@@ -247,7 +260,8 @@ def get_component_templates(courselike, library=False): # lint-amnesty, pylint:
|
||||
'problem': _("Problem"),
|
||||
'video': _("Video"),
|
||||
'openassessment': _("Open Response"),
|
||||
'library': _("Library Content"),
|
||||
'library': _("Legacy Library"),
|
||||
'library_v2': _("Library Content"),
|
||||
'drag-and-drop-v2': _("Drag and Drop"),
|
||||
}
|
||||
|
||||
@@ -277,7 +291,7 @@ def get_component_templates(courselike, library=False): # lint-amnesty, pylint:
|
||||
templates_for_category = []
|
||||
component_class = _load_mixed_class(category)
|
||||
|
||||
if support_level_without_template and category != 'library':
|
||||
if support_level_without_template and category not in ['library']:
|
||||
# add the default template with localized display name
|
||||
# TODO: Once mixins are defined per-application, rather than per-runtime,
|
||||
# this should use a cms mixed-in class. (cpennington)
|
||||
@@ -472,6 +486,8 @@ def _filter_disabled_blocks(all_blocks):
|
||||
Filter out disabled xblocks from the provided list of xblock names.
|
||||
"""
|
||||
disabled_block_names = [block.name for block in disabled_xblocks()]
|
||||
if not libraries_v2_enabled():
|
||||
disabled_block_names.append('library_v2')
|
||||
return [block_name for block_name in all_blocks if block_name not in disabled_block_names]
|
||||
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ from cms.djangoapps.contentstore.config.waffle import SHOW_REVIEW_RULES_FLAG
|
||||
from cms.djangoapps.contentstore.toggles import ENABLE_DEFAULT_ADVANCED_PROBLEM_EDITOR_FLAG
|
||||
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
|
||||
from cms.lib.ai_aside_summary_config import AiAsideSummaryConfig
|
||||
from cms.lib.xblock.upstream_sync import BadUpstream, sync_from_upstream
|
||||
from common.djangoapps.static_replace import replace_static_urls
|
||||
from common.djangoapps.student.auth import (
|
||||
has_studio_read_access,
|
||||
@@ -586,6 +587,18 @@ def _create_block(request):
|
||||
boilerplate=request.json.get("boilerplate"),
|
||||
)
|
||||
|
||||
# If it contains library_content_key, the block is being imported from a v2 library
|
||||
# so it needs to be synced with upstream block.
|
||||
if upstream_ref := request.json.get("library_content_key"):
|
||||
try:
|
||||
# Set `created_block.upstream` and then sync this with the upstream (library) version.
|
||||
created_block.upstream = upstream_ref
|
||||
sync_from_upstream(downstream=created_block, user=request.user)
|
||||
except BadUpstream:
|
||||
_delete_item(created_block.location, request.user)
|
||||
return JsonResponse({"error": _("Invalid library xblock reference.")}, status=400)
|
||||
modulestore().update_item(created_block, request.user.id)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"locator": str(created_block.location),
|
||||
|
||||
BIN
cms/static/images/large-library_v2-icon.png
Normal file
BIN
cms/static/images/large-library_v2-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 584 B |
68
cms/static/js/views/components/add_library_content.js
Normal file
68
cms/static/js/views/components/add_library_content.js
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Provides utilities to open and close the library content picker.
|
||||
*
|
||||
*/
|
||||
define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal'],
|
||||
function($, _, gettext, BaseModal) {
|
||||
'use strict';
|
||||
|
||||
var AddLibraryContent = BaseModal.extend({
|
||||
options: $.extend({}, BaseModal.prototype.options, {
|
||||
modalName: 'add-component-from-library',
|
||||
modalSize: 'lg',
|
||||
view: 'studio_view',
|
||||
viewSpecificClasses: 'modal-add-component-picker confirm',
|
||||
// Translators: "title" is the name of the current component being edited.
|
||||
titleFormat: gettext('Add library content'),
|
||||
addPrimaryActionButton: false,
|
||||
}),
|
||||
|
||||
initialize: function() {
|
||||
BaseModal.prototype.initialize.call(this);
|
||||
// Add event listen to close picker when the iframe tells us to
|
||||
const handleMessage = (event) => {
|
||||
if (event.data?.type === 'pickerComponentSelected') {
|
||||
var requestData = {
|
||||
library_content_key: event.data.usageKey,
|
||||
category: event.data.category,
|
||||
}
|
||||
this.refreshFunction(requestData);
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
this.messageListener = window.addEventListener("message", handleMessage);
|
||||
this.cleanupListener = () => { window.removeEventListener("message", handleMessage) };
|
||||
},
|
||||
|
||||
hide: function() {
|
||||
BaseModal.prototype.hide.call(this);
|
||||
this.cleanupListener();
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds the action buttons to the modal.
|
||||
*/
|
||||
addActionButtons: function() {
|
||||
this.addActionButton('cancel', gettext('Cancel'));
|
||||
},
|
||||
|
||||
/**
|
||||
* Show a component picker modal from library.
|
||||
* @param contentPickerUrl Url for component picker
|
||||
* @param refreshFunction A function to refresh the block after it has been updated
|
||||
*/
|
||||
showComponentPicker: function(contentPickerUrl, refreshFunction) {
|
||||
this.contentPickerUrl = contentPickerUrl;
|
||||
this.refreshFunction = refreshFunction;
|
||||
|
||||
this.render();
|
||||
this.show();
|
||||
},
|
||||
|
||||
getContentHtml: function() {
|
||||
return `<iframe src="${this.contentPickerUrl}" onload="this.contentWindow.focus()" frameborder="0" style="width: 100%; height: 100%;"/>`;
|
||||
},
|
||||
});
|
||||
|
||||
return AddLibraryContent;
|
||||
});
|
||||
@@ -3,8 +3,9 @@
|
||||
*/
|
||||
define(['jquery', 'underscore', 'gettext', 'js/views/baseview', 'common/js/components/utils/view_utils',
|
||||
'js/views/components/add_xblock_button', 'js/views/components/add_xblock_menu',
|
||||
'js/views/components/add_library_content',
|
||||
'edx-ui-toolkit/js/utils/html-utils'],
|
||||
function($, _, gettext, BaseView, ViewUtils, AddXBlockButton, AddXBlockMenu, HtmlUtils) {
|
||||
function($, _, gettext, BaseView, ViewUtils, AddXBlockButton, AddXBlockMenu, AddLibraryContent, HtmlUtils) {
|
||||
'use strict';
|
||||
|
||||
var AddXBlockComponent = BaseView.extend({
|
||||
@@ -67,14 +68,32 @@ function($, _, gettext, BaseView, ViewUtils, AddXBlockButton, AddXBlockMenu, Htm
|
||||
oldOffset = ViewUtils.getScrollOffset(this.$el);
|
||||
event.preventDefault();
|
||||
this.closeNewComponent(event);
|
||||
ViewUtils.runOperationShowingMessage(
|
||||
gettext('Adding'),
|
||||
_.bind(this.options.createComponent, this, saveData, $element)
|
||||
).always(function() {
|
||||
// Restore the scroll position of the buttons so that the new
|
||||
// component appears above them.
|
||||
ViewUtils.setScrollOffset(self.$el, oldOffset);
|
||||
});
|
||||
|
||||
if (saveData.type === 'library_v2') {
|
||||
var modal = new AddLibraryContent();
|
||||
modal.showComponentPicker(
|
||||
this.options.libraryContentPickerUrl,
|
||||
function(data) {
|
||||
ViewUtils.runOperationShowingMessage(
|
||||
gettext('Adding'),
|
||||
_.bind(this.options.createComponent, this, data, $element),
|
||||
).always(function() {
|
||||
// Restore the scroll position of the buttons so that the new
|
||||
// component appears above them.
|
||||
ViewUtils.setScrollOffset(self.$el, oldOffset);
|
||||
});
|
||||
}.bind(this)
|
||||
);
|
||||
} else {
|
||||
ViewUtils.runOperationShowingMessage(
|
||||
gettext('Adding'),
|
||||
_.bind(this.options.createComponent, this, saveData, $element),
|
||||
).always(function() {
|
||||
// Restore the scroll position of the buttons so that the new
|
||||
// component appears above them.
|
||||
ViewUtils.setScrollOffset(self.$el, oldOffset);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -214,7 +214,8 @@ function($, _, Backbone, gettext, BasePage,
|
||||
var component = new AddXBlockComponent({
|
||||
el: element,
|
||||
createComponent: _.bind(self.createComponent, self),
|
||||
collection: self.options.templates
|
||||
collection: self.options.templates,
|
||||
libraryContentPickerUrl: self.options.libraryContentPickerUrl,
|
||||
});
|
||||
component.render();
|
||||
});
|
||||
@@ -224,7 +225,7 @@ function($, _, Backbone, gettext, BasePage,
|
||||
},
|
||||
|
||||
initializePasteButton() {
|
||||
if (this.options.canEdit && !self.options.isIframeEmbed) {
|
||||
if (this.options.canEdit && !this.options.isIframeEmbed) {
|
||||
// We should have the user's clipboard status.
|
||||
const data = this.options.clipboardData;
|
||||
this.refreshPasteButton(data);
|
||||
@@ -241,7 +242,7 @@ function($, _, Backbone, gettext, BasePage,
|
||||
refreshPasteButton(data) {
|
||||
// Do not perform any changes on paste button since they are not
|
||||
// rendered on Library or LibraryContent pages
|
||||
if (!this.isLibraryPage && !this.isLibraryContentPage && !self.options.isIframeEmbed) {
|
||||
if (!this.isLibraryPage && !this.isLibraryContentPage && !this.options.isIframeEmbed) {
|
||||
// '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) {
|
||||
|
||||
@@ -66,3 +66,10 @@
|
||||
height: ($baseline*3);
|
||||
background: url('#{$static-path}/images/large-library-icon.png') center no-repeat;
|
||||
}
|
||||
|
||||
.large-library_v2-icon {
|
||||
display: inline-block;
|
||||
width: ($baseline*3);
|
||||
height: ($baseline*3);
|
||||
background: url('#{$static-path}/images/large-library_v2-icon.png') center no-repeat;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// studio - elements - side drawers
|
||||
// ====================
|
||||
|
||||
.drawer-cover {
|
||||
.drawer-cover,
|
||||
.picker-cover {
|
||||
@extend %ui-depth3;
|
||||
|
||||
display: none;
|
||||
@@ -13,7 +14,8 @@
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.drawer-cover.gray-cover {
|
||||
.drawer-cover.gray-cover,
|
||||
.picker-cover.gray-cover {
|
||||
background: rgba(112, 112, 112, 0.8);
|
||||
}
|
||||
|
||||
@@ -29,6 +31,20 @@
|
||||
background-color: $gray-l4;
|
||||
}
|
||||
|
||||
body.drawer-open {
|
||||
body.drawer-open,
|
||||
body.picker-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.picker {
|
||||
@extend %ui-depth4;
|
||||
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 1em;
|
||||
left: 1em;
|
||||
right: 1em;
|
||||
bottom: 1em;
|
||||
background-color: $gray-l4;
|
||||
}
|
||||
|
||||
|
||||
@@ -580,3 +580,18 @@
|
||||
width:100%;
|
||||
height:100%
|
||||
}
|
||||
|
||||
// Modal for adding components from libary using component picker
|
||||
// cms/static/js/views/components/add_library_content_with_picker.js
|
||||
.modal-add-component-picker {
|
||||
top: 10%;
|
||||
.modal-content {
|
||||
padding: 0 !important;
|
||||
|
||||
& > iframe {
|
||||
width: 100%;
|
||||
min-height: 80vh;
|
||||
background: url('#{$static-path}/images/spinner.gif') center center no-repeat;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ from openedx.core.djangolib.markup import HTML, Text
|
||||
isUnitPage: ${is_unit_page | n, dump_js_escaped_json},
|
||||
canEdit: true,
|
||||
outlineURL: "${outline_url | n, js_escaped_string}",
|
||||
libraryContentPickerUrl: "${library_content_picker_url | n, js_escaped_string}",
|
||||
clipboardData: ${user_clipboard | n, dump_js_escaped_json},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -179,6 +179,7 @@ def get_user_clipboard_json(user_id: int, request: HttpRequest | None = None):
|
||||
except _UserClipboard.DoesNotExist:
|
||||
# This user does not have any content on their clipboard.
|
||||
return {"content": None, "source_usage_key": "", "source_context_title": "", "source_edit_url": ""}
|
||||
|
||||
serializer = _UserClipboardSerializer(
|
||||
_user_clipboard_model_to_data(clipboard),
|
||||
context={'request': request},
|
||||
|
||||
Reference in New Issue
Block a user