Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: adds Library Content button to Studio Unit page [FC-0062] #35670

2 changes: 2 additions & 0 deletions cms/djangoapps/contentstore/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down
8 changes: 5 additions & 3 deletions cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 34 additions & 23 deletions cms/djangoapps/contentstore/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions cms/djangoapps/contentstore/views/block.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +138 to +139
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having this POST arg will be nice, so that we don't have to follow up the request with a PUT 👍🏻

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kdmccormick Yes, called upstream sync after creating the block in a single call, simplified the js part. bc27ba3

The locator (unicode representation of a UsageKey) for the created xblock (minus children) is returned.
"""
return handle_xblock(request, usage_key_string)
Expand Down
24 changes: 20 additions & 4 deletions cms/djangoapps/contentstore/views/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"),
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
Binary file added cms/static/images/large-library_v2-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
68 changes: 68 additions & 0 deletions cms/static/js/views/components/add_library_content.js
Original file line number Diff line number Diff line change
@@ -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'));
Copy link
Contributor

@bradenmacdonald bradenmacdonald Oct 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be fixed in a fast-follow PR (because I'm eager to get this into testing ASAP), but it's not a great UX that the "Next" button is sometimes hidden until you scroll, and the Next + Cancel buttons are in totally different places. I think they need to either be both inside the modal or both in the legy UI actions bar.
sceenshot

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, do we even need a Next button? Selecting a course should directly take to next page since we only have one item to select in the first slide.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed next button and updated it to go to component selection on library selection.

vokoscreenNG-2024-10-21_11-16-03.mp4

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense to me!

},

/**
* 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;
});
37 changes: 28 additions & 9 deletions cms/static/js/views/components/add_xblock.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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);
});
}
}
});

Expand Down
7 changes: 4 additions & 3 deletions cms/static/js/views/pages/container.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand All @@ -224,7 +225,7 @@ function($, _, Backbone, gettext, BasePage,
},

initializePasteButton() {
if (this.options.canEdit && !self.options.isIframeEmbed) {
if (this.options.canEdit && !this.options.isIframeEmbed) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixes a typo/bug introduced in #35587

// We should have the user's clipboard status.
const data = this.options.clipboardData;
this.refreshPasteButton(data);
Expand All @@ -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) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixes a typo/bug introduced in #35587

// '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) {
Expand Down
7 changes: 7 additions & 0 deletions cms/static/sass/assets/_graphics.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading
Loading