feat: Show a preview of what's in the user's clipboard (#32132)
This also fixes Studio container view 404.
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,5 +15,16 @@
|
||||
<span class="icon fa fa-paste" aria-hidden="true"></span>
|
||||
${_('Paste Component')}
|
||||
</button>
|
||||
<div class="paste-component-whats-in-clipboard" tabindex="0">
|
||||
<!-- These details get filled in by JavaScript code when it makes the paste button visible: -->
|
||||
<a href="#" class="clipboard-details-popup" onClick="if (this.getAttribute('href') === '#') return false;" target="_blank">
|
||||
<span class="fa fa-external-link" aria-hidden="true"></span>
|
||||
<strong class="detail-block-name">Block Name</strong>
|
||||
<span class="detail-block-type">Type</span>
|
||||
${_("From:")} <span class="detail-course-name">Course Name Goes Here</span>
|
||||
</a>
|
||||
<span class="icon fa fa-question-circle" aria-hidden="true"></span>
|
||||
${_("What's in my clipboard?")}
|
||||
</div>
|
||||
</div>
|
||||
% endif
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user