Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cms/djangoapps/contentstore/views/block.py
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,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")
Expand All @@ -499,7 +499,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):
Expand Down
5 changes: 4 additions & 1 deletion cms/djangoapps/contentstore/views/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,11 +330,14 @@ def create_support_legend_dict():
# 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')
)
Expand Down
28 changes: 25 additions & 3 deletions cms/djangoapps/contentstore/views/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
"""
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions cms/djangoapps/contentstore/views/tests/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ def test_xblock_studio_url(self):
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()
Expand Down
23 changes: 21 additions & 2 deletions cms/static/js/views/pages/container.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Comment thread
Agrendalath marked this conversation as resolved.
} 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();
}
Expand Down
84 changes: 84 additions & 0 deletions cms/static/sass/elements/_modules.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions lms/templates/studio_render_children_view.html
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion openedx/core/djangoapps/content_staging/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
31 changes: 31 additions & 0 deletions openedx/core/djangoapps/content_staging/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
"""
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


Expand All @@ -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
Expand All @@ -21,11 +26,16 @@ class Meta:
'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):
"""
Expand All @@ -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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ def test_empty_clipboard(self):
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(
Expand Down Expand Up @@ -86,6 +87,7 @@ def test_copy_video(self):
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",
Expand Down Expand Up @@ -190,6 +192,7 @@ def test_copy_several_things(self):
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))

Expand Down
7 changes: 6 additions & 1 deletion openedx/core/djangoapps/content_staging/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,12 @@ def get(self, request):
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)

Expand Down