Add UI for "library sourced block" letting users browse and select xblocks (#24816)

This commit is contained in:
Sid Verma
2020-10-14 22:59:52 +05:30
committed by GitHub
parent 7234f849a0
commit 09c24be35e
11 changed files with 439 additions and 75 deletions

View File

@@ -0,0 +1,223 @@
/* globals gettext */
import 'whatwg-fetch';
import PropTypes from 'prop-types';
import React from 'react';
import _ from 'underscore';
import styles from './style.css';
class LibrarySourcedBlockPicker extends React.Component {
constructor(props) {
super(props);
this.state = {
libraries: [],
xblocks: [],
searchedLibrary: '',
libraryLoading: false,
xblocksLoading: false,
selectedLibrary: undefined,
selectedXblocks: new Set(this.props.selectedXblocks),
};
this.onLibrarySearchInput = this.onLibrarySearchInput.bind(this);
this.onXBlockSearchInput = this.onXBlockSearchInput.bind(this);
this.onLibrarySelected = this.onLibrarySelected.bind(this);
this.onXblockSelected = this.onXblockSelected.bind(this);
this.onDeleteClick = this.onDeleteClick.bind(this);
}
componentDidMount() {
this.fetchLibraries();
}
fetchLibraries(textSearch='', page=1, append=false) {
this.setState({
libraries: append ? this.state.libraries : [],
libraryLoading: true,
}, async function() {
try {
let res = await fetch(`/api/libraries/v2/?pagination=true&page=${page}&text_search=${textSearch}`);
res = await res.json();
this.setState({
libraries: this.state.libraries.concat(res.results),
libraryLoading: false,
}, () => {
if (res.next) {
this.fetchLibraries(textSearch, page+1, true);
}
});
} catch (error) {
$('#library-sourced-block-picker').trigger('error', {
title: 'Could not fetch library',
message: error,
});
this.setState({
libraries: [],
libraryLoading: false,
});
}
});
}
fetchXblocks(library, textSearch='', page=1, append=false) {
this.setState({
xblocks: append ? this.state.xblocks : [],
xblocksLoading: true,
}, async function() {
try {
let res = await fetch(`/api/libraries/v2/${library}/blocks/?pagination=true&page=${page}&text_search=${textSearch}`);
res = await res.json();
this.setState({
xblocks: this.state.xblocks.concat(res.results),
xblocksLoading: false,
}, () => {
if (res.next) {
this.fetchXblocks(library, textSearch, page+1, true);
}
});
} catch (error) {
$('#library-sourced-block-picker').trigger('error', {
title: 'Could not fetch xblocks',
message: error,
});
this.setState({
xblocks: [],
xblocksLoading: false,
});
}
});
}
onLibrarySearchInput(event) {
event.persist()
this.setState({
searchedLibrary: event.target.value,
});
if (!this.debouncedFetchLibraries) {
this.debouncedFetchLibraries = _.debounce(value => {
this.fetchLibraries(value);
}, 300);
}
this.debouncedFetchLibraries(event.target.value);
}
onXBlockSearchInput(event) {
event.persist()
if (!this.debouncedFetchXblocks) {
this.debouncedFetchXblocks = _.debounce(value => {
this.fetchXblocks(this.state.selectedLibrary, value);
}, 300);
}
this.debouncedFetchXblocks(event.target.value);
}
onLibrarySelected(event) {
this.setState({
selectedLibrary: event.target.value,
});
this.fetchXblocks(event.target.value);
}
onXblockSelected(event) {
let state = new Set(this.state.selectedXblocks);
if (event.target.checked) {
state.add(event.target.value);
} else {
state.delete(event.target.value);
}
this.setState({
selectedXblocks: state,
}, this.updateList);
}
onDeleteClick(event) {
let value;
if (event.target.tagName == 'SPAN') {
value = event.target.parentElement.dataset.value;
} else {
value = event.target.dataset.value;
}
let state = new Set(this.state.selectedXblocks);
state.delete(value);
this.setState({
selectedXblocks: state,
}, this.updateList);
}
updateList(list) {
$('#library-sourced-block-picker').trigger('selected-xblocks', {
sourceBlockIds: Array.from(this.state.selectedXblocks),
});
}
render() {
return (
<section>
<div className="container-message wrapper-message">
<div className="message has-warnings" style={{margin: 0, color: "white"}}>
<p className="warning">
<span className="icon fa fa-warning" aria-hidden="true"></span>
Hitting 'Save and Import' will import the latest versions of the selected blocks, overwriting any changes done to this block post-import.
</p>
</div>
</div>
<div style={{display: "flex", flexDirection: "row", justifyContent: "center"}}>
<div className={styles.column}>
<input type="text" className={[styles.search]} aria-label="Search for library" placeholder="Search for library" label="Search for library" name="librarySearch" onChange={this.onLibrarySearchInput}/>
<div className={styles.elementList} onChange={this.onLibrarySelected}>
{
this.state.libraries.map(lib => (
<div key={lib.id} className={styles.element}>
<input id={`sourced-library-${lib.id}`} type="radio" value={lib.id} name="library"/>
<label className={styles.elementItem} htmlFor={`sourced-library-${lib.id}`}>{lib.title}</label>
</div>
))
}
{ this.state.libraryLoading && <span>{gettext('Loading...')}</span> }
</div>
</div>
<div className={styles.column}>
<input type="text" className={[styles.search]} aria-label="Search for XBlocks" placeholder="Search for XBlocks" name="xblockSearch" onChange={this.onXBlockSearchInput} disabled={!this.state.selectedLibrary || this.state.libraryLoading}/>
<div className={styles.elementList} onChange={this.onXblockSelected}>
{
this.state.xblocks.map(block => (
<div key={block.id} className={styles.element}>
<input id={`sourced-block-${block.id}`} type="checkbox" value={block.id} name="block" checked={this.state.selectedXblocks.has(block.id)} readOnly/>
<label className={styles.elementItem} htmlFor={`sourced-block-${block.id}`}>{block.display_name} ({block.id})</label>
</div>
))
}
{ this.state.xblocksLoading && <span>{gettext('Loading...')}</span> }
</div>
</div>
<div className={styles.column}>
<h4 className={styles.selectedBlocks}>{gettext('Selected blocks')}</h4>
<ul>
{
Array.from(this.state.selectedXblocks).map(block => (
<li key={block} className={styles.element} style={{display: "flex"}}>
<label className={styles.elementItem}>
{block}
</label>
<button className={[styles.remove]} data-value={block} onClick={this.onDeleteClick} aria-label="Remove block">
<span aria-hidden="true" className="icon fa fa-times"></span>
</button>
</li>
))
}
</ul>
</div>
</div>
</section>
);
}
}
LibrarySourcedBlockPicker.propTypes = {
selectedXblocks: PropTypes.array,
};
LibrarySourcedBlockPicker.defaultProps = {
selectedXblocks: [],
};
export { LibrarySourcedBlockPicker }; // eslint-disable-line import/prefer-default-export

View File

@@ -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', {});
});
};

View File

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

View File

@@ -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('</div>')
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)

View File

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

View File

@@ -1,9 +0,0 @@
<div class="xblock-render">
<p>
<i>To display a component from a content library here, enter the component ID (XBlock ID) that you want to use:</i>
</p>
<div style="display: flex">
<input type="text" name="source_component_id" style="margin-right: 10px;"/>
<button class="btn-brand save-btn" data-submit-url="{{save_url}}">Save</button>
</div>
</div>

View File

@@ -0,0 +1,19 @@
{% load i18n %}
<div class="editor-with-buttons">
<div class="wrapper-comp-settings is-active editor-with-buttons" id="settings-tab">
<div class="list-input settings-list">
{{ react_content|safe }}
</div>
</div>
<div class="xblock-actions">
<ul>
<li class="action-item">
<a href="#" class="button action-primary save-button" data-submit-url="{{save_url}}">{% trans "Save and Import" as tmsg %} {{tmsg|force_escape}}</a>
</li>
<li class="action-item">
<a href="#" class="button cancel-button">{% trans "Cancel" as tmsg %} {{tmsg|force_escape}}</a>
</li>
</ul>
</div>
</div>

View File

@@ -19,50 +19,48 @@ class LibrarySourcedBlockTestCase(ContentLibrariesRestApiTest):
def setUp(self):
super().setUp()
self.store = modulestore()
def test_block_views(self):
# Create a blockstore content library
library = self._create_library(slug="testlib1_preview", title="Test Library 1", description="Testing XBlocks")
# Add content to the library
html_block_id = self._add_block_to_library(library["id"], "html", "html_student_preview")["id"]
self._set_library_block_olx(html_block_id, '<html>Student Preview Test</html>')
# Create a modulestore course
course = CourseFactory.create(modulestore=self.store, user_id=self.user.id)
CourseInstructorRole(course.id).add_users(self.user)
# Add a "Source from Library" block to the course
source_block = ItemFactory.create(
self.source_block = ItemFactory.create(
category="library_sourced",
parent=course,
parent_location=course.location,
user_id=self.user.id,
modulestore=self.store
)
self.submit_url = '/xblock/{0}/handler/submit_studio_edits'.format(self.source_block.scope_ids.usage_id)
# Check if author_view for empty block renders using the editor template
html = source_block.render(AUTHOR_VIEW).content
loader = ResourceLoader('xmodule.library_sourced_block')
expected_html = loader.render_django_template('templates/library-sourced-block-author-view.html', {
'save_url': handler_url(source_block, 'submit_studio_edits')
})
self.assertEqual(expected_html, html)
def test_block_views(self):
# Create a blockstore content library
library = self._create_library(slug="testlib1_preview", title="Test Library 1", description="Testing XBlocks")
# Add content to the library
html_block_1 = self._add_block_to_library(library["id"], "html", "html_student_preview_1")["id"]
self._set_library_block_olx(html_block_1, '<html>Student Preview Test 1</html>')
html_block_2 = self._add_block_to_library(library["id"], "html", "html_student_preview_2")["id"]
self._set_library_block_olx(html_block_2, '<html>Student Preview Test 2</html>')
submit_studio_edits_url = '/xblock/{0}/handler/submit_studio_edits'.format(source_block.scope_ids.usage_id)
post_data = {"values": {"source_block_id": html_block_id}, "defaults": ["display_name"]}
# Import the html block from the library to the course
self.client.post(submit_studio_edits_url, data=post_data, format='json')
# Check if author_view for a configured block renders the children correctly
# Use self.get_block_view for rendering these as mako templates are mocked to return repr of the template
# instead of the rendered html
res = self.get_block_view(source_block, AUTHOR_VIEW)
self.assertNotIn('library-sourced-block-author-view.html', res)
self.assertIn('studio_render_children_view.html', res)
self.assertIn('Student Preview Test', res)
# Import the html blocks from the library to the course
post_data = {"values": {"source_block_ids": [html_block_1, html_block_2]}, "defaults": ["display_name"]}
res = self.client.post(self.submit_url, data=post_data, format='json')
# Check if student_view renders the children correctly
res = self.get_block_view(source_block, STUDENT_VIEW)
self.assertIn('Student Preview Test', res)
res = self.get_block_view(self.source_block, STUDENT_VIEW)
self.assertIn('Student Preview Test 1', res)
self.assertIn('Student Preview Test 2', res)
def test_block_limits(self):
# Create a blockstore content library
library = self._create_library(slug="testlib2_preview", title="Test Library 2", description="Testing XBlocks")
# Add content to the library
blocks = [self._add_block_to_library(library["id"], "html", "block_{0}".format(i))["id"] for i in range(11)]
# Import the html blocks from the library to the course
post_data = {"values": {"source_block_ids": blocks}, "defaults": ["display_name"]}
res = self.client.post(self.submit_url, data=post_data, format='json')
self.assertEqual(res.status_code, 400)
self.assertEqual(res.json()['error']['messages'][0]['text'], "A maximum of 10 components may be added.")
def get_block_view(self, block, view, context=None):
"""

View File

@@ -66,7 +66,7 @@ class ContentLibraryToolsTest(MixedSplitTestCase, ContentLibrariesRestApiTest):
sourced_block = self.make_block("library_sourced", course, user_id=self.user.id)
# Import the unit block from the library to the course
self.tools.import_from_blockstore(sourced_block, unit_block_id)
self.tools.import_from_blockstore(sourced_block, [unit_block_id])
# Verify imported block with its children
self.assertEqual(len(sourced_block.children), 1)
@@ -86,7 +86,7 @@ class ContentLibraryToolsTest(MixedSplitTestCase, ContentLibrariesRestApiTest):
# Check that reimporting updates the target block
self._set_library_block_olx(html_block_id, '<html><a href="/static/test.txt">Foo bar</a></html>')
self.tools.import_from_blockstore(sourced_block, unit_block_id)
self.tools.import_from_blockstore(sourced_block, [unit_block_id])
self.assertEqual(len(sourced_block.children), 1)
imported_unit_block = self.store.get_item(sourced_block.children[0])

View File

@@ -72,6 +72,7 @@ module.exports = Merge.smart({
// Studio
Import: './cms/static/js/features/import/factories/import.js',
CourseOrLibraryListing: './cms/static/js/features_jsx/studio/CourseOrLibraryListing.jsx',
LibrarySourcedBlockPicker: './common/lib/xmodule/xmodule/assets/library_source_block/LibrarySourcedBlockPicker.jsx', // eslint-disable-line max-len
'js/factories/textbooks': './cms/static/js/factories/textbooks.js',
'js/factories/container': './cms/static/js/factories/container.js',
'js/factories/context_course': './cms/static/js/factories/context_course.js',
@@ -346,6 +347,18 @@ module.exports = Merge.smart({
{
test: /logger/,
loader: 'imports-loader?this=>window'
},
{
test: /\.css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: true
}
}
]
}
]
},

View File

@@ -26,7 +26,7 @@ module.exports = _.values(Merge.smart(commonConfig, {
module: {
rules: [
{
test: /(.scss|.css)$/,
test: /.scss$/,
include: [
/paragon/,
/font-awesome/