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:
Braden MacDonald
2023-05-05 08:35:03 -07:00
committed by GitHub
parent c34f8efc0e
commit 4b72194b98
11 changed files with 191 additions and 11 deletions

View File

@@ -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):

View File

@@ -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')
)

View File

@@ -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

View File

@@ -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()

View File

@@ -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();
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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):

View File

@@ -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))

View File

@@ -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)