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')} +