From 7e8fb4393e42eae61749ec3221498eb865fd9fe8 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 21 Oct 2024 23:02:54 +0530 Subject: [PATCH] feat: adds Library Content (v2) button to Studio Unit page (#35670) Requires that v2 libraries are enabled. --- cms/djangoapps/contentstore/helpers.py | 2 + .../rest_api/v1/serializers/vertical_block.py | 1 + .../rest_api/v2/views/downstreams.py | 8 ++- cms/djangoapps/contentstore/utils.py | 57 +++++++++------ cms/djangoapps/contentstore/views/block.py | 2 + .../contentstore/views/component.py | 24 +++++-- .../xblock_storage_handlers/view_handlers.py | 13 ++++ cms/static/images/large-library_v2-icon.png | Bin 0 -> 584 bytes .../views/components/add_library_content.js | 68 ++++++++++++++++++ cms/static/js/views/components/add_xblock.js | 37 +++++++--- cms/static/js/views/pages/container.js | 7 +- cms/static/sass/assets/_graphics.scss | 7 ++ cms/static/sass/elements/_drawer.scss | 22 +++++- cms/static/sass/views/_container.scss | 15 ++++ cms/templates/container.html | 1 + .../core/djangoapps/content_staging/api.py | 1 + 16 files changed, 220 insertions(+), 45 deletions(-) create mode 100644 cms/static/images/large-library_v2-icon.png create mode 100644 cms/static/js/views/components/add_library_content.js diff --git a/cms/djangoapps/contentstore/helpers.py b/cms/djangoapps/contentstore/helpers.py index bc8f03f186bf..4366f11d81ac 100644 --- a/cms/djangoapps/contentstore/helpers.py +++ b/cms/djangoapps/contentstore/helpers.py @@ -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 diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py index f2e8b6ef431b..dffa23349d92 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py @@ -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): """ diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py index 5079698082be..8c0d651910a6 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py @@ -131,11 +131,13 @@ def put(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response # 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 diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 940137b1d5d1..d40a5ec79475 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -38,7 +38,28 @@ 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.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 diff --git a/cms/djangoapps/contentstore/views/block.py b/cms/djangoapps/contentstore/views/block.py index 4d6c17838e57..e6b41dc261d2 100644 --- a/cms/djangoapps/contentstore/views/block.py +++ b/cms/djangoapps/contentstore/views/block.py @@ -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) diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 8fbadad799fc..b89bef0f6709 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -26,7 +26,7 @@ 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 @@ 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 create_support_legend_dict(): '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 create_support_legend_dict(): 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] diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index e4d37f942331..a4adbb0d4cbe 100644 --- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py @@ -36,6 +36,7 @@ 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), diff --git a/cms/static/images/large-library_v2-icon.png b/cms/static/images/large-library_v2-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..5242104c4ce6703d8ff3f8a53ea6fc8136b460fb GIT binary patch literal 584 zcmV-O0=NB%P)o8o|Asm< zyq~CgJJj3f9$)qhKya-JVvE4e6@WXuzo~jl)O#U-%mkPLnE^Lk0@MJCAa?<7gM9*J z_yB0zHR8HZv-=uwc}bT5`W440?oym$&rwd<(I)!<4CEOdKB!KwFsO}kc}W0tc3B+b zEgrGXNt~hy00{mP0e`6Tf}AN%r_8$F#smNvFd+PNGw+Xo0SK!Y2$ldmA<#z^>>2=W zY5^eP_ZtXy9wUZkGN4-qa%2`E^h=Z&S_2?r_@w|uiJ>U~ImFN+fTUt*5kOKgGzTEH z7+M98Obkr|NGXPv0fY`?2RlQ{0FsEISpccT&^iFQl#yeyU*w`>KK6iQ8h{eR4cT>!y!%qskt?oyHEZwsb}2Ng_`w zlQZDh0~X0000 { + 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 `