Add UI for "library sourced block" letting users browse and select xblocks (#24816)
This commit is contained in:
@@ -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
|
||||
@@ -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', {});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -26,7 +26,7 @@ module.exports = _.values(Merge.smart(commonConfig, {
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /(.scss|.css)$/,
|
||||
test: /.scss$/,
|
||||
include: [
|
||||
/paragon/,
|
||||
/font-awesome/
|
||||
|
||||
Reference in New Issue
Block a user