diff --git a/cms/djangoapps/contentstore/views/block.py b/cms/djangoapps/contentstore/views/block.py index 0c46ea2e76..2cadea08ea 100644 --- a/cms/djangoapps/contentstore/views/block.py +++ b/cms/djangoapps/contentstore/views/block.py @@ -475,7 +475,7 @@ def xblock_outline_handler(request, usage_key_string): include_children_predicate=lambda xblock: not xblock.category == 'vertical' )) else: - return Http404 + raise Http404 @require_http_methods("GET") @@ -500,7 +500,7 @@ def xblock_container_handler(request, usage_key_string): ) return JsonResponse(response) else: - return Http404 + raise Http404 def _update_with_callback(xblock, user, old_metadata=None, old_content=None): diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 8eee09471e..60d6f68c9a 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -330,11 +330,14 @@ def get_component_templates(courselike, library=False): # lint-amnesty, pylint: # TODO: Once mixins are defined per-application, rather than per-runtime, # this should use a cms mixed-in class. (cpennington) template_id = None - display_name = xblock_type_display_name(category, _('Blank')) # this is the Blank Advanced problem + display_name = xblock_type_display_name(category, _('Blank')) # The ORA "blank" assessment should be Peer Assessment Only if category == 'openassessment': display_name = _("Peer Assessment Only") template_id = "peer-assessment" + elif category == 'problem': + # Override generic "Problem" name to describe this blank template: + display_name = _("Blank Advanced Problem") templates_for_category.append( create_template_dict(display_name, category, support_level_without_template, template_id, 'advanced') ) diff --git a/cms/djangoapps/contentstore/views/helpers.py b/cms/djangoapps/contentstore/views/helpers.py index 2d82469e63..0c2ba76fac 100644 --- a/cms/djangoapps/contentstore/views/helpers.py +++ b/cms/djangoapps/contentstore/views/helpers.py @@ -101,12 +101,27 @@ def xblock_has_own_studio_page(xblock, parent_xblock=None): return xblock.has_children -def xblock_studio_url(xblock, parent_xblock=None): +def xblock_studio_url(xblock, parent_xblock=None, find_parent=False): """ Returns the Studio editing URL for the specified xblock. + + You can pass the parent xblock as an optimization, to avoid needing to load + it twice, as sometimes the parent has to be checked. + + If you pass in a leaf block that doesn't have its own Studio page, this will + normally return None, but if you use find_parent=True, this will find the + nearest ancestor (usually the parent unit) that does have a Studio page and + return that URL. """ if not xblock_has_own_studio_page(xblock, parent_xblock): - return None + if find_parent: + while xblock and not xblock_has_own_studio_page(xblock, parent_xblock): + xblock = parent_xblock or get_parent_xblock(xblock) + parent_xblock = None + if not xblock: + return None + else: + return None category = xblock.category if category == 'course': return reverse_course_url('course_handler', xblock.location.course_key) @@ -127,7 +142,7 @@ def xblock_type_display_name(xblock, default_display_name=None): Returns the display name for the specified type of xblock. Note that an instance can be passed in for context dependent names, e.g. a vertical beneath a sequential is a Unit. - :param xblock: An xblock instance or the type of xblock. + :param xblock: An xblock instance or the type of xblock (as a string). :param default_display_name: The default value to return if no display name can be found. :return: """ @@ -144,6 +159,13 @@ def xblock_type_display_name(xblock, default_display_name=None): return _('Subsection') elif category == 'vertical': return _('Unit') + elif category == 'problem': + # The problem XBlock's display_name.default is not helpful ("Blank Advanced Problem") but changing it could have + # too many ripple effects in other places, so we have a special case for capa problems here. + # Note: With a ProblemBlock instance, we could actually check block.problem_types to give a more specific + # description like "Multiple Choice Problem", but that won't work if our 'block' argument is just the block_type + # string ("problem"). + return _('Problem') 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/views/tests/test_helpers.py b/cms/djangoapps/contentstore/views/tests/test_helpers.py index 5d4a831417..d83c09e583 100644 --- a/cms/djangoapps/contentstore/views/tests/test_helpers.py +++ b/cms/djangoapps/contentstore/views/tests/test_helpers.py @@ -52,6 +52,8 @@ class HelpersTestCase(CourseTestCase): video = BlockFactory.create(parent_location=child_vertical.location, category="video", display_name="My Video") self.assertIsNone(xblock_studio_url(video)) + # Verify video URL with find_parent=True + self.assertEqual(xblock_studio_url(video, find_parent=True), f'/container/{child_vertical.location}') # Verify library URL library = LibraryFactory.create() diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index 0814beb7dc..1793f28e75 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -209,8 +209,27 @@ function($, _, Backbone, gettext, BasePage, ViewUtils, ContainerView, XBlockView // '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) { - // TODO: check if this is suitable for pasting into a unit - this.$(".paste-component").show(); + if (["vertical", "sequential", "chapter", "course"].includes(data.content.block_type)) { + // This is not suitable for pasting into a unit. + this.$(".paste-component").hide(); + } else if (data.content.status === "expired") { + // This has expired and can no longer be pasted. + this.$(".paste-component").hide(); + } else { + // The thing in the clipboard can be pasted into this unit: + 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(); } diff --git a/cms/static/sass/elements/_modules.scss b/cms/static/sass/elements/_modules.scss index 142aea5f4e..1eb5be08cc 100644 --- a/cms/static/sass/elements/_modules.scss +++ b/cms/static/sass/elements/_modules.scss @@ -335,6 +335,90 @@ @extend %ui-btn-flat-outline; } + + .paste-component-whats-in-clipboard { + position: relative; + margin-left: auto; + width: fit-content; + font-size: 1.2rem; + margin-top: 4px; + cursor: help; + + &:hover, &:focus, &:focus-within { + .clipboard-details-popup { + display: block; + } + } + + .clipboard-details-popup { + display: none; + position: absolute; + bottom: 3.3rem; + right: calc(50% - 125px); + width: 250px; + background: white; + border-radius: 2px; + box-shadow: 0 0 3px #ddd; + padding: 15px; + color: inherit; + + // The callout triangle below this popup: + &::before { + content: ''; + position: absolute; + width: 0; + height: 0; + top: 100%; + left: calc(50% - 0.4rem); + // Use different border widths to render a triangle: + border: .8rem solid transparent; + border-bottom: none; + border-top-color: #fff; + filter: drop-shadow(0 1px 1px rgba(0,0,0,0.1)) + } + + // This invisible rectangle makes it easy for users to move their mouse from the "What's in my clipboard?" widget + // to the popover without it disappearing on them. + &::after { + content: ''; + position: absolute; + top: 100%; + left: 0; + right: 0; + height: 1rem; + } + + .fa-external-link { + position: absolute; + top: 10px; + right: 10px; + } + + &.no-edit-link { + // If there is no "edit" URL available, make this not look like a link. + .fa-external-link { + display: none; + } + + cursor: default; + } + + .detail-block-name { + font-size: 1.5rem; + } + + .detail-block-type { + color: #888; + font-size: 1rem; + display: block; + margin-top: -0.6rem; + } + + .detail-course-name { + font-style: italic; + } + } + } } // outline UI diff --git a/lms/templates/studio_render_children_view.html b/lms/templates/studio_render_children_view.html index aab3d1fa7f..6d1793cee1 100644 --- a/lms/templates/studio_render_children_view.html +++ b/lms/templates/studio_render_children_view.html @@ -15,5 +15,16 @@ ${_('Paste Component')} +
+ + + + Block Name + Type + ${_("From:")} Course Name Goes Here + + + ${_("What's in my clipboard?")} +
% endif diff --git a/openedx/core/djangoapps/content_staging/api.py b/openedx/core/djangoapps/content_staging/api.py index e3af7494f5..8e43458956 100644 --- a/openedx/core/djangoapps/content_staging/api.py +++ b/openedx/core/djangoapps/content_staging/api.py @@ -59,7 +59,7 @@ def get_user_clipboard_json(user_id: int, request: HttpRequest = None): clipboard = _UserClipboard.objects.get(user_id=user_id) except _UserClipboard.DoesNotExist: # This user does not have any content on their clipboard. - return {"content": None, "source_usage_key": "", "source_context_title": ""} + return {"content": None, "source_usage_key": "", "source_context_title": "", "source_edit_url": ""} serializer = _UserClipboardSerializer(clipboard, context={'request': request}) return serializer.data diff --git a/openedx/core/djangoapps/content_staging/serializers.py b/openedx/core/djangoapps/content_staging/serializers.py index f91e58a871..90e79892fc 100644 --- a/openedx/core/djangoapps/content_staging/serializers.py +++ b/openedx/core/djangoapps/content_staging/serializers.py @@ -3,6 +3,10 @@ Serializers for the content libraries REST API """ from rest_framework import serializers +from cms.djangoapps.contentstore.views.helpers import xblock_studio_url, xblock_type_display_name +from common.djangoapps.student.auth import has_studio_read_access +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.exceptions import ItemNotFoundError from .models import StagedContent @@ -11,6 +15,7 @@ class StagedContentSerializer(serializers.ModelSerializer): Serializer for staged content. Doesn't include the OLX by default. """ olx_url = serializers.HyperlinkedIdentityField(view_name="staged-content-olx", lookup_field="id") + block_type_display = serializers.SerializerMethodField(source="get_block_type_display") class Meta: model = StagedContent @@ -21,11 +26,16 @@ class StagedContentSerializer(serializers.ModelSerializer): 'purpose', 'status', 'block_type', + 'block_type_display', # We don't include OLX; it may be large. But we include the URL to retrieve it. 'olx_url', 'display_name', ] + def get_block_type_display(self, obj): + """ Get the friendly name for this XBlock/component type """ + return xblock_type_display_name(obj.block_type) + class UserClipboardSerializer(serializers.Serializer): """ @@ -35,6 +45,27 @@ class UserClipboardSerializer(serializers.Serializer): source_usage_key = serializers.CharField(allow_blank=True) # The title of the course that the content came from originally, if relevant source_context_title = serializers.CharField(allow_blank=True, source="get_source_context_title") + # The URL where the original content can be seen, if it still exists and the current user can view it + source_edit_url = serializers.SerializerMethodField(source="get_source_edit_url") + + def get_source_edit_url(self, obj) -> str: + """ Get the URL where the user can edit the given XBlock, if it exists """ + request = self.context.get("request", None) + user = request.user if request else None + if not user: + return "" + if not obj.source_usage_key.context_key.is_course: + return "" # Linking back to libraries is not implemented yet + if not has_studio_read_access(user, obj.source_usage_key.course_key): + return "" + try: + block = modulestore().get_item(obj.source_usage_key) + except ItemNotFoundError: + return "" + edit_url = xblock_studio_url(block, find_parent=True) + if edit_url: + return request.build_absolute_uri(edit_url) + return "" class PostToClipboardSerializer(serializers.Serializer): diff --git a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py index 0f7622fde4..b12da4d95c 100644 --- a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py +++ b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py @@ -45,7 +45,8 @@ class ClipboardTestCase(ModuleStoreTestCase): self.assertEqual(response.json(), { "content": None, "source_usage_key": "", - "source_context_title": "" + "source_context_title": "", + "source_edit_url": "", }) ## The Python method for getting the API response should be identical: self.assertEqual( @@ -86,6 +87,7 @@ class ClipboardTestCase(ModuleStoreTestCase): self.assertEqual(response_data["source_context_title"], "Toy Course") self.assertEqual(response_data["content"], {**response_data["content"], **{ "block_type": "video", + "block_type_display": "Video", # To ensure API stability, we are hard-coding these expected values: "purpose": "clipboard", "status": "ready", @@ -190,6 +192,7 @@ class ClipboardTestCase(ModuleStoreTestCase): html_clip_data = response.json() self.assertEqual(html_clip_data["source_usage_key"], str(html_key)) self.assertEqual(html_clip_data["content"]["block_type"], "html") + self.assertEqual(html_clip_data["content"]["block_type_display"], "Text") ## The Python method for getting the API response should be identical: self.assertEqual(html_clip_data, python_api.get_user_clipboard_json(self.user.id, response.wsgi_request)) diff --git a/openedx/core/djangoapps/content_staging/views.py b/openedx/core/djangoapps/content_staging/views.py index b27f04bbf0..ecebffda98 100644 --- a/openedx/core/djangoapps/content_staging/views.py +++ b/openedx/core/djangoapps/content_staging/views.py @@ -74,7 +74,12 @@ class ClipboardEndpoint(APIView): clipboard = UserClipboard.objects.get(user=request.user.id) except UserClipboard.DoesNotExist: # This user does not have any content on their clipboard. - return Response({"content": None, "source_usage_key": "", "source_context_title": ""}) + return Response({ + "content": None, + "source_usage_key": "", + "source_context_title": "", + "source_edit_url": "", + }) serializer = UserClipboardSerializer(clipboard, context={"request": request}) return Response(serializer.data)