feat: adds Library Content (v2) button to Studio Unit page (#35670)

Requires that v2 libraries are enabled.
This commit is contained in:
Navin Karkera
2024-10-21 23:02:54 +05:30
committed by GitHub
parent e2d6765637
commit 7e8fb4393e
16 changed files with 220 additions and 45 deletions

View File

@@ -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

View File

@@ -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):
"""

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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]

View File

@@ -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),

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 B

View 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;
});

View File

@@ -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);
});
}
}
});

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}
}

View File

@@ -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},
}
);

View File

@@ -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},