+
+ );
+ }
+}
+
+LibrarySourcedBlockPicker.propTypes = {
+ selectedXblocks: PropTypes.array,
+};
+
+LibrarySourcedBlockPicker.defaultProps = {
+ selectedXblocks: [],
+};
+
+export { LibrarySourcedBlockPicker }; // eslint-disable-line import/prefer-default-export
diff --git a/common/lib/xmodule/xmodule/assets/library_source_block/public/js/library_source_block.js b/common/lib/xmodule/xmodule/assets/library_source_block/public/js/library_source_block.js
index cbbe6c0790..9674080d59 100644
--- a/common/lib/xmodule/xmodule/assets/library_source_block/public/js/library_source_block.js
+++ b/common/lib/xmodule/xmodule/assets/library_source_block/public/js/library_source_block.js
@@ -1,17 +1,25 @@
-/* JavaScript for allowing editing options on LibrarySourceBlock's author view */
-window.LibrarySourceBlockAuthorView = function(runtime, element) {
+/* JavaScript for allowing editing options on LibrarySourceBlock's studio view */
+window.LibrarySourceBlockStudioView = function(runtime, element) {
'use strict';
- var $element = $(element);
+ var self = this;
- $element.on('click', '.save-btn', function(e) {
+ $('#library-sourced-block-picker', element).on('selected-xblocks', function(e, params) {
+ self.sourceBlockIds = params.sourceBlockIds;
+ });
+
+ $('#library-sourced-block-picker', element).on('error', function(e, params) {
+ runtime.notify('error', {title: gettext(params.title), message: params.message});
+ });
+
+ $('.save-button', element).on('click', function(e) {
+ e.preventDefault();
var url = $(e.target).data('submit-url');
var data = {
values: {
- source_block_id: $element.find('input').val()
+ source_block_ids: self.sourceBlockIds
},
defaults: ['display_name']
};
- e.preventDefault();
runtime.notify('save', {
state: 'start',
@@ -42,4 +50,9 @@ window.LibrarySourceBlockAuthorView = function(runtime, element) {
runtime.notify('error', {title: gettext('Unable to update settings'), message: message});
});
});
+
+ $('.cancel-button', element).on('click', function(e) {
+ e.preventDefault();
+ runtime.notify('cancel', {});
+ });
};
diff --git a/common/lib/xmodule/xmodule/assets/library_source_block/style.css b/common/lib/xmodule/xmodule/assets/library_source_block/style.css
new file mode 100644
index 0000000000..4892f20405
--- /dev/null
+++ b/common/lib/xmodule/xmodule/assets/library_source_block/style.css
@@ -0,0 +1,58 @@
+.column {
+ display: flex;
+ flex-direction: column;
+ margin: 10px;
+ max-width: 300px;
+ flex-grow: 1;
+}
+.elementList {
+ margin-top: 10px;
+}
+input.search {
+ width: 100% !important;
+ height: auto !important;
+}
+.element > input[type='checkbox'],
+.element > input[type='radio'] {
+ position: absolute;
+ width: 0 !important;
+ height: 0 !important;
+ top: -9999px;
+}
+.element > .elementItem {
+ display: flex;
+ flex-grow: 1;
+ padding: 0.625rem 1.25rem;
+ border: 1px solid rgba(0, 0, 0, 0.25);
+}
+.element + .element > label {
+ border-top: 0;
+}
+.element > input[type='checkbox']:focus + label,
+.element > input[type='radio']:focus + label,
+.element > input:hover + label {
+ background: #f6f6f7;
+ cursor: pointer;
+}
+.element > input:checked + label {
+ background: #23419f;
+ color: #fff;
+}
+.element > input[type='checkbox']:checked:focus + label,
+.element > input[type='radio']:checked:focus + label,
+.element > input:checked:hover + label {
+ background: #193787;
+ cursor: pointer;
+}
+.selectedBlocks {
+ padding: 12px 8px 20px;
+}
+button.remove {
+ background: #e00;
+ color: #fff;
+ border: solid rgba(0,0,0,0.25) 1px;
+}
+button.remove:focus,
+button.remove:hover {
+ background: #d00;
+}
diff --git a/common/lib/xmodule/xmodule/library_sourced_block.py b/common/lib/xmodule/xmodule/library_sourced_block.py
index 205bb7f4a0..4005ffabb1 100644
--- a/common/lib/xmodule/xmodule/library_sourced_block.py
+++ b/common/lib/xmodule/xmodule/library_sourced_block.py
@@ -4,12 +4,14 @@ Library Sourced Content XBlock
import logging
from copy import copy
-from web_fragments.fragment import Fragment
+from mako.template import Template as MakoTemplate
from xblock.core import XBlock
-from xblock.fields import Scope, String
+from xblock.fields import Scope, String, List
+from xblock.validation import ValidationMessage
from xblockutils.resources import ResourceLoader
from xblockutils.studio_editable import StudioEditableXBlockMixin
from webob import Response
+from web_fragments.fragment import Fragment
from cms.lib.xblock.runtime import handler_url
from xmodule.studio_editable import StudioEditableBlock as EditableChildrenMixin
@@ -41,38 +43,54 @@ class LibrarySourcedBlock(StudioEditableXBlockMixin, EditableChildrenMixin, XBlo
display_name=_("Display Name"),
scope=Scope.content,
)
- source_block_id = String(
- display_name=_("Library Block"),
- help=_("Enter the IDs of the library XBlock that you wish to use."),
+ source_block_ids = List(
+ display_name=_("Library Blocks List"),
+ help=_("Enter the IDs of the library XBlocks that you wish to use."),
scope=Scope.content,
)
- editable_fields = ("display_name", "source_block_id")
+ editable_fields = ("display_name", "source_block_ids")
has_children = True
has_author_view = True
resources_dir = 'assets/library_source_block'
+ MAX_BLOCKS_ALLOWED = 10
def __str__(self):
return "LibrarySourcedBlock: {}".format(self.display_name)
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ if not self.source_block_ids:
+ self.has_children = False
+
+ def studio_view(self, context):
+ """
+ Render a form for editing this XBlock
+ """
+ fragment = Fragment()
+ static_content = ResourceLoader('common.djangoapps.pipeline_mako').load_unicode('templates/static_content.html')
+ render_react = MakoTemplate(static_content, default_filters=[]).get_def('renderReact')
+ react_content = render_react.render(
+ component="LibrarySourcedBlockPicker",
+ id="library-sourced-block-picker",
+ props={
+ 'selectedXblocks': self.source_block_ids,
+ }
+ )
+ fragment.content = loader.render_django_template('templates/library-sourced-block-studio-view.html', {
+ 'react_content': react_content,
+ 'save_url': handler_url(self, 'submit_studio_edits'),
+ })
+
+ fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/library_source_block.js'))
+ fragment.initialize_js('LibrarySourceBlockStudioView')
+
+ return fragment
+
def author_view(self, context):
"""
Renders the Studio preview view.
"""
fragment = Fragment()
- root_xblock = context.get('root_xblock')
- is_root = root_xblock and root_xblock.location == self.location # pylint: disable=no-member
- # If block ID is not defined, ask user for the component ID in the author_view itself.
- # We don't display the editor if is_root as that page should represent the student_view without any ambiguity
- if not self.source_block_id and not is_root:
- fragment.add_content(
- loader.render_django_template('templates/library-sourced-block-author-view.html', {
- 'save_url': handler_url(self, 'submit_studio_edits')
- })
- )
- fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/library_source_block.js'))
- fragment.initialize_js('LibrarySourceBlockAuthorView')
- return fragment
-
context = {} if not context else copy(context) # Isolate context - without this there are weird bugs in Studio
# EditableChildrenMixin.render_children will render HTML that allows instructors to make edits to the children
context['can_move'] = False
@@ -92,6 +110,21 @@ class LibrarySourcedBlock(StudioEditableXBlockMixin, EditableChildrenMixin, XBlo
result.add_content('')
return result
+ def validate_field_data(self, validation, data):
+ """
+ Validate this block's field data. Instead of checking fields like self.name, check the
+ fields set on data, e.g. data.name. This allows the same validation method to be re-used
+ for the studio editor.
+ """
+ if len(data.source_block_ids) > self.MAX_BLOCKS_ALLOWED:
+ # Because importing library blocks is an expensive operation
+ validation.add(
+ ValidationMessage(
+ ValidationMessage.ERROR,
+ _(u"A maximum of {0} components may be added.").format(self.MAX_BLOCKS_ALLOWED)
+ )
+ )
+
def validate(self):
"""
Validates the state of this library_sourced_xblock Instance. This is the override of the general XBlock method,
@@ -100,11 +133,11 @@ class LibrarySourcedBlock(StudioEditableXBlockMixin, EditableChildrenMixin, XBlo
validation = super().validate()
validation = StudioValidation.copy(validation)
- if not self.source_block_id:
+ if not self.source_block_ids:
validation.set_summary(
StudioValidationMessage(
StudioValidationMessage.NOT_CONFIGURED,
- _(u"No XBlock has been configured for this component. Enter the target ID below or in the editor"),
+ _(u"No XBlock has been configured for this component. Use the editor to select the target blocks."),
action_class='edit-button',
action_label=_(u"Open Editor")
)
@@ -120,7 +153,7 @@ class LibrarySourcedBlock(StudioEditableXBlockMixin, EditableChildrenMixin, XBlo
# Replace our current children with the latest ones from the libraries.
lib_tools = self.runtime.service(self, 'library_tools')
try:
- lib_tools.import_from_blockstore(self, self.source_block_id)
+ lib_tools.import_from_blockstore(self, self.source_block_ids)
except Exception as err: # pylint: disable=broad-except
log.exception(err)
return Response(_(u"Importing Library Block failed - are the IDs valid and readable?"), status=400)
diff --git a/common/lib/xmodule/xmodule/library_tools.py b/common/lib/xmodule/xmodule/library_tools.py
index 6968eb1ccd..b3589b78c8 100644
--- a/common/lib/xmodule/xmodule/library_tools.py
+++ b/common/lib/xmodule/xmodule/library_tools.py
@@ -13,6 +13,7 @@ from xblock.fields import Scope
from openedx.core.djangoapps.content_libraries import api as library_api
from openedx.core.djangoapps.xblock.api import load_block
+from openedx.core.lib import blockstore_api
from student.auth import has_studio_write_access
from xmodule.capa_module import ProblemBlock
from xmodule.library_content_module import ANY_CAPA_TYPE_VALUE
@@ -190,7 +191,7 @@ class LibraryToolsService(object):
for lib in self.store.get_library_summaries()
]
- def import_from_blockstore(self, dest_block, blockstore_block_id):
+ def import_from_blockstore(self, dest_block, blockstore_block_ids):
"""
Imports a block from a blockstore-based learning context (usually a
content library) into modulestore, as a new child of dest_block.
@@ -205,18 +206,31 @@ class LibraryToolsService(object):
if self.user_id is None:
raise ValueError("Cannot check user permissions - LibraryTools user_id is None")
+ if len(set(blockstore_block_ids)) != len(blockstore_block_ids):
+ # We don't support importing the exact same block twice because it would break the way we generate new IDs
+ # for each block and then overwrite existing copies of blocks when re-importing the same blocks.
+ raise ValueError("One or more library component IDs is a duplicate.")
+
dest_course_key = dest_key.context_key
user = User.objects.get(id=self.user_id)
if not has_studio_write_access(user, dest_course_key):
raise PermissionDenied()
# Read the source block; this will also confirm that user has permission to read it.
- orig_block = load_block(UsageKey.from_string(blockstore_block_id), user)
+ # (This could be slow and use lots of memory, except for the fact that LibrarySourcedBlock which calls this
+ # should be limiting the number of blocks to a reasonable limit. We load them all now instead of one at a
+ # time in order to raise any errors before we start actually copying blocks over.)
+ orig_blocks = [load_block(UsageKey.from_string(key), user) for key in blockstore_block_ids]
with self.store.bulk_operations(dest_course_key):
- new_block_id = self._import_block(orig_block, dest_key)
+ child_ids_updated = set()
+
+ for block in orig_blocks:
+ new_block_id = self._import_block(block, dest_key)
+ child_ids_updated.add(new_block_id)
+
# Remove any existing children that are no longer used
- for old_child_id in set(dest_block.children) - set([new_block_id]):
+ for old_child_id in set(dest_block.children) - child_ids_updated:
self.store.delete_item(old_child_id, self.user_id)
# If this was called from a handler, it will save dest_block at the end, so we must update
# dest_block.children to avoid it saving the old value of children and deleting the new ones.
@@ -273,6 +287,8 @@ class LibraryToolsService(object):
# If string field (which may also be JSON/XML data), rewrite /static/... URLs to point to blockstore
for asset in all_assets:
field_value = field_value.replace('/static/{}'.format(asset.path), asset.url)
+ # Make sure the URL is one that will work from the user's browser when using the docker devstack
+ field_value = blockstore_api.force_browser_url(field_value)
setattr(new_block, field_name, field_value)
new_block.save()
self.store.update_item(new_block, self.user_id)
diff --git a/common/lib/xmodule/xmodule/templates/library-sourced-block-author-view.html b/common/lib/xmodule/xmodule/templates/library-sourced-block-author-view.html
deleted file mode 100644
index 003d3b6181..0000000000
--- a/common/lib/xmodule/xmodule/templates/library-sourced-block-author-view.html
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
- To display a component from a content library here, enter the component ID (XBlock ID) that you want to use:
-
-
-
-
-
-
\ No newline at end of file
diff --git a/common/lib/xmodule/xmodule/templates/library-sourced-block-studio-view.html b/common/lib/xmodule/xmodule/templates/library-sourced-block-studio-view.html
new file mode 100644
index 0000000000..08a2882b51
--- /dev/null
+++ b/common/lib/xmodule/xmodule/templates/library-sourced-block-studio-view.html
@@ -0,0 +1,19 @@
+{% load i18n %}
+