feat!: remove library_sourced block (#33257)
Originally, we planned to add support for V2 libraries and for static (hand-selected) library block reference via new block type: library_sourced. We have since decided that it would be better to add those capabilities in-place to the existing library_content block. This will ease V1->V2 library migration and make adoption of the new features easier for current library users. It will also avoid duplication of logic between two block types, we we fear would be error-prone. For details, see this ADR: https://github.com/openedx/edx-platform/pull/33231 So, we are removing the library_sourced block. This block has existed in edx-platform for a few years now, but was not enabled by default and never officially supported. It was only usable via the experimental V2 content library feature. Operators who added library_sourced blocks to their course will now see them render as `HiddenBlock` instances, i.e.: > ERROR: "library_sourced" is an unknown component type... This should not impact other component types in such courses and should not impact import/export.
This commit is contained in:
@@ -2675,10 +2675,7 @@ class TestComponentTemplates(CourseTestCase):
|
||||
XBlockStudioConfiguration.objects.create(
|
||||
name="openassessment", enabled=True, support_level="us"
|
||||
)
|
||||
# Library Sourced Block and Library Content block has it's own category.
|
||||
XBlockStudioConfiguration.objects.create(
|
||||
name="library_sourced", enabled=True, support_level="fs"
|
||||
)
|
||||
# Library Content block has its own category.
|
||||
XBlockStudioConfiguration.objects.create(
|
||||
name="library_content", enabled=True, support_level="fs"
|
||||
)
|
||||
|
||||
@@ -3,7 +3,13 @@ Referencing Content Blocks in Library V2
|
||||
|
||||
Status
|
||||
=======
|
||||
Pending
|
||||
|
||||
**Deferred** as of September 2023.
|
||||
|
||||
The goals described in the ADR are still relevant to future development,
|
||||
but for the intial launch of Blockstore-backed content libraries,
|
||||
the existing ``library_content`` block will be used instead,
|
||||
and it will continue to copy blocks from Blockstore into Modulestore as necessary.
|
||||
|
||||
Context
|
||||
=======
|
||||
|
||||
@@ -452,7 +452,7 @@ def xblock_resource_pkg(block):
|
||||
ProblemBlock, and most other built-in blocks currently. Handling for these
|
||||
assets does not interact with this function.
|
||||
2. The (preferred) standard XBlock runtime resource loading system, used by
|
||||
LibrarySourcedBlock. Handling for these assets *does* interact with this
|
||||
LibraryContentBlock. Handling for these assets *does* interact with this
|
||||
function.
|
||||
|
||||
We hope to migrate to (2) eventually, tracked by:
|
||||
|
||||
1
setup.py
1
setup.py
@@ -22,7 +22,6 @@ XBLOCKS = [
|
||||
"image = xmodule.template_block:TranslateCustomTagBlock",
|
||||
"library = xmodule.library_root_xblock:LibraryRoot",
|
||||
"library_content = xmodule.library_content_block:LibraryContentBlock",
|
||||
"library_sourced = xmodule.library_sourced_block:LibrarySourcedBlock",
|
||||
"lti = xmodule.lti_block:LTIBlock",
|
||||
"poll_question = xmodule.poll_block:PollBlock",
|
||||
"problem = xmodule.capa_block:ProblemBlock",
|
||||
|
||||
@@ -77,7 +77,6 @@ module.exports = Merge.smart({
|
||||
// Studio
|
||||
Import: './cms/static/js/features/import/factories/import.js',
|
||||
CourseOrLibraryListing: './cms/static/js/features_jsx/studio/CourseOrLibraryListing.jsx',
|
||||
LibrarySourcedBlockPicker: './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',
|
||||
|
||||
@@ -46,11 +46,6 @@ It is collected into the static root, and then linked to from XBlock fragments b
|
||||
.. _annotatable/_display.scss: https://github.com/openedx/edx-platform/tree/master/xmodule/assets/annotatable/_display.scss
|
||||
.. _simplify things: https://github.com/openedx/edx-platform/issues/32621
|
||||
|
||||
Static CSS (.css)
|
||||
*****************
|
||||
|
||||
Non-themable, ready-to-seve CSS may also be added to the any block type's
|
||||
subdirectory. For example, see `library_source_block/style.css`_.
|
||||
|
||||
JavaScript (.js)
|
||||
****************
|
||||
@@ -72,7 +67,7 @@ Currently, edx-platform XBlock JS is defined both here in `xmodule/assets`_ and
|
||||
* For other "purer" blocks, the JS is used as a standard XBlock fragment. Example blocks:
|
||||
|
||||
* `VerticalBlock`_
|
||||
* `LibrarySourcedBlock`_
|
||||
* `LibraryContentBlock`_
|
||||
|
||||
As part of an `active build refactoring`_, we will soon consolidate all edx-platform XBlock JS here in `xmodule/assets`_.
|
||||
|
||||
@@ -82,7 +77,7 @@ As part of an `active build refactoring`_, we will soon consolidate all edx-plat
|
||||
.. _HtmlBlock: https://github.com/openedx/edx-platform/blob/master/xmodule/html_block.py
|
||||
.. _AnnotatableBlock: https://github.com/openedx/edx-platform/blob/master/xmodule/annotatable_block.py
|
||||
.. _VerticalBlock: https://github.com/openedx/edx-platform/blob/master/xmodule/vertical_block.py
|
||||
.. _LibrarySourcedBlock: https://github.com/openedx/edx-platform/blob/master/xmodule/library_sourced_block.py
|
||||
.. _LibraryContentBlock: https://github.com/openedx/edx-platform/blob/master/xmodule/library_content_block.py
|
||||
.. _active build refactoring: https://github.com/openedx/edx-platform/issues/31624
|
||||
.. _builtin_assets.py: https://github.com/openedx/edx-platform/tree/master/xmodule/util/builtin_assets.py
|
||||
.. _static_content.py: https://github.com/openedx/edx-platform/blob/master/xmodule/static_content.py
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
/* 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: [],
|
||||
// eslint-disable-next-line react/no-unused-state
|
||||
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();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/sort-comp
|
||||
fetchLibraries(textSearch = '', page = 1, append = false) {
|
||||
this.setState({
|
||||
// eslint-disable-next-line react/no-access-state-in-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({
|
||||
// eslint-disable-next-line react/no-access-state-in-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({
|
||||
// eslint-disable-next-line react/no-access-state-in-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({
|
||||
// eslint-disable-next-line react/no-access-state-in-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({
|
||||
// eslint-disable-next-line react/no-unused-state
|
||||
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) {
|
||||
// eslint-disable-next-line prefer-const, react/no-access-state-in-setstate
|
||||
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;
|
||||
}
|
||||
// eslint-disable-next-line prefer-const, react/no-access-state-in-setstate
|
||||
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" />
|
||||
{/* eslint-disable-next-line react/no-unescaped-entities */}
|
||||
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'}}>
|
||||
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||
<label className={styles.elementItem}>
|
||||
{block}
|
||||
</label>
|
||||
{/* eslint-disable-next-line react/button-has-type */}
|
||||
<button className={[styles.remove]} data-value={block} onClick={this.onDeleteClick} aria-label="Remove block">
|
||||
<span aria-hidden="true" className="icon fa fa-times" />
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
LibrarySourcedBlockPicker.propTypes = {
|
||||
// eslint-disable-next-line react/forbid-prop-types
|
||||
selectedXblocks: PropTypes.array,
|
||||
};
|
||||
|
||||
LibrarySourcedBlockPicker.defaultProps = {
|
||||
selectedXblocks: [],
|
||||
};
|
||||
|
||||
export {LibrarySourcedBlockPicker}; // eslint-disable-line import/prefer-default-export
|
||||
@@ -1,58 +0,0 @@
|
||||
/* JavaScript for allowing editing options on LibrarySourceBlock's studio view */
|
||||
window.LibrarySourceBlockStudioView = function(runtime, element) {
|
||||
'use strict';
|
||||
var self = this;
|
||||
|
||||
$('#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_ids: self.sourceBlockIds
|
||||
},
|
||||
defaults: ['display_name']
|
||||
};
|
||||
|
||||
runtime.notify('save', {
|
||||
state: 'start',
|
||||
message: gettext('Saving'),
|
||||
element: element
|
||||
});
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: url,
|
||||
data: JSON.stringify(data),
|
||||
global: false // Disable error handling that conflicts with studio's notify('save') and notify('cancel')
|
||||
}).done(function() {
|
||||
runtime.notify('save', {
|
||||
state: 'end',
|
||||
element: element
|
||||
});
|
||||
}).fail(function(jqXHR) {
|
||||
var message = gettext('This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.'); // eslint-disable-line max-len
|
||||
if (jqXHR.responseText) { // Is there a more specific error message we can show?
|
||||
try {
|
||||
message = JSON.parse(jqXHR.responseText).error;
|
||||
if (typeof message === 'object' && message.messages) {
|
||||
// e.g. {"error": {"messages": [{"text": "Unknown user 'bob'!", "type": "error"}, ...]}} etc.
|
||||
message = $.map(message.messages, function(msg) { return msg.text; }).join(', ');
|
||||
}
|
||||
} catch (error) { message = jqXHR.responseText.substr(0, 300); }
|
||||
}
|
||||
runtime.notify('error', {title: gettext('Unable to update settings'), message: message});
|
||||
});
|
||||
});
|
||||
|
||||
$('.cancel-button', element).on('click', function(e) {
|
||||
e.preventDefault();
|
||||
runtime.notify('cancel', {});
|
||||
});
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
"""
|
||||
Library Sourced Content XBlock
|
||||
"""
|
||||
import logging
|
||||
|
||||
from copy import copy
|
||||
from mako.template import Template as MakoTemplate
|
||||
from xblock.core import XBlock
|
||||
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 xmodule.studio_editable import StudioEditableBlock as EditableChildrenMixin
|
||||
from xmodule.validation import StudioValidation, StudioValidationMessage
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
loader = ResourceLoader(__name__)
|
||||
|
||||
# Make '_' a no-op so we can scrape strings. Using lambda instead of
|
||||
# `django.utils.translation.ugettext_noop` because Django cannot be imported in this file
|
||||
_ = lambda text: text
|
||||
|
||||
|
||||
@XBlock.wants('library_tools') # Only needed in studio
|
||||
class LibrarySourcedBlock(StudioEditableXBlockMixin, EditableChildrenMixin, XBlock):
|
||||
"""
|
||||
Library Sourced Content XBlock
|
||||
|
||||
Allows copying specific XBlocks from a Blockstore-based content library into
|
||||
a modulestore-based course. The selected blocks are copied and become
|
||||
children of this block.
|
||||
|
||||
When we implement support for Blockstore-based courses, it's expected we'll
|
||||
use a different mechanism for importing library content into a course.
|
||||
"""
|
||||
display_name = String(
|
||||
help=_("The display name for this component."),
|
||||
default="Library Sourced Content",
|
||||
display_name=_("Display Name"),
|
||||
scope=Scope.content,
|
||||
)
|
||||
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_ids")
|
||||
has_children = True
|
||||
has_author_view = True
|
||||
resources_dir = 'assets/library_source_block'
|
||||
MAX_BLOCKS_ALLOWED = 10
|
||||
|
||||
def __str__(self):
|
||||
return f"LibrarySourcedBlock: {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': self.runtime.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()
|
||||
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
|
||||
self.render_children(context, fragment, can_reorder=False, can_add=False)
|
||||
return fragment
|
||||
|
||||
def student_view(self, context):
|
||||
"""
|
||||
Renders the view that learners see.
|
||||
"""
|
||||
result = Fragment()
|
||||
child_frags = self.runtime.render_children(self, context=context)
|
||||
result.add_resources(child_frags)
|
||||
result.add_content('<div class="library-sourced-content">')
|
||||
for frag in child_frags:
|
||||
result.add_content(frag.content)
|
||||
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,
|
||||
_("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,
|
||||
and it will also ask its superclass to validate.
|
||||
"""
|
||||
validation = super().validate()
|
||||
validation = StudioValidation.copy(validation)
|
||||
|
||||
if not self.source_block_ids:
|
||||
validation.set_summary(
|
||||
StudioValidationMessage(
|
||||
StudioValidationMessage.NOT_CONFIGURED,
|
||||
_("No XBlock has been configured for this component. Use the editor to select the target blocks."),
|
||||
action_class='edit-button',
|
||||
action_label=_("Open Editor")
|
||||
)
|
||||
)
|
||||
return validation
|
||||
|
||||
@XBlock.handler
|
||||
def submit_studio_edits(self, data, suffix=''):
|
||||
"""
|
||||
Save changes to this block, applying edits made in Studio.
|
||||
"""
|
||||
response = super().submit_studio_edits(data, suffix)
|
||||
# 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_ids)
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
log.exception(err)
|
||||
return Response(_("Importing Library Block failed - are the IDs valid and readable?"), status=400)
|
||||
return response
|
||||
@@ -196,7 +196,7 @@ class LibraryToolsService:
|
||||
content library) into modulestore, as a new child of dest_block.
|
||||
Any existing children of dest_block are replaced.
|
||||
|
||||
This is only used by LibrarySourcedBlock. It should verify first that
|
||||
This is only used by LibraryContentBlock. It should verify first that
|
||||
the number of block IDs is reasonable.
|
||||
"""
|
||||
dest_key = dest_block.scope_ids.usage_id
|
||||
@@ -216,7 +216,7 @@ class LibraryToolsService:
|
||||
raise PermissionDenied()
|
||||
|
||||
# Read the source block; this will also confirm that user has permission to read it.
|
||||
# (This could be slow and use lots of memory, except for the fact that LibrarySourcedBlock which calls this
|
||||
# (This could be slow and use lots of memory, except for the fact that LibraryContentBlock 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]
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
{% 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>
|
||||
@@ -1,71 +0,0 @@
|
||||
"""
|
||||
Tests for Source from Library XBlock
|
||||
"""
|
||||
|
||||
from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest
|
||||
from common.djangoapps.student.roles import CourseInstructorRole
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory
|
||||
from xmodule.tests import prepare_block_runtime
|
||||
from xmodule.x_module import STUDENT_VIEW # lint-amnesty, pylint: disable=unused-import
|
||||
|
||||
|
||||
class LibrarySourcedBlockTestCase(ContentLibrariesRestApiTest):
|
||||
"""
|
||||
Tests for LibraryToolsService which interact with blockstore-based content libraries
|
||||
"""
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.store = modulestore()
|
||||
# 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
|
||||
self.source_block = BlockFactory.create(
|
||||
category="library_sourced",
|
||||
parent=course,
|
||||
parent_location=course.location,
|
||||
user_id=self.user.id,
|
||||
modulestore=self.store
|
||||
)
|
||||
self.submit_url = f'/xblock/{self.source_block.scope_ids.usage_id}/handler/submit_studio_edits'
|
||||
|
||||
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>')
|
||||
|
||||
# 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(self.source_block, STUDENT_VIEW)
|
||||
assert 'Student Preview Test 1' in res
|
||||
assert 'Student Preview Test 2' in 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", f"block_{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')
|
||||
assert res.status_code == 400
|
||||
assert res.json()['error']['messages'][0]['text'] == 'A maximum of 10 components may be added.'
|
||||
|
||||
def get_block_view(self, block, view, context=None):
|
||||
"""
|
||||
Renders the specified view for a given XBlock
|
||||
"""
|
||||
context = context or {}
|
||||
block = self.store.get_item(block.location)
|
||||
prepare_block_runtime(block.runtime)
|
||||
block.bind_for_student(self.user.id)
|
||||
return block.runtime.render(block, view, context).content
|
||||
Reference in New Issue
Block a user