Add new container page that can display nested xblocks
This is the changes for STUD-1244, which introduces the ability for Studio to display arbitrarily nested xblocks. In this change, a new container page is introduced which can display nested xblocks. In particular, the xblock type of 'vertical' is special cased to be shown inline as a collapsible section. The unit page is mostly unchanged, except that container xblock's are shown as a link to their container page, rather than being shown inline.
This commit is contained in:
@@ -5,12 +5,14 @@ These are notable changes in edx-platform. This is a rolling list of changes,
|
||||
in roughly chronological order, most recent first. Add your entries at or near
|
||||
the top. Include a label indicating the component affected.
|
||||
|
||||
Studio: Add new container page that can display nested xblocks. STUD-1244.
|
||||
|
||||
Blades: Allow multiple transcripts with video. BLD-642.
|
||||
|
||||
CMS: Add feature to allow exporting a course to a git repository by
|
||||
specifying the giturl in the course settings.
|
||||
|
||||
Studo: Fix import/export bug with conditional modules. STUD-149
|
||||
Studio: Fix import/export bug with conditional modules. STUD-149
|
||||
|
||||
Blades: Persist student progress in video. BLD-385.
|
||||
|
||||
|
||||
@@ -484,7 +484,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests the ajax callback to render an XModule
|
||||
"""
|
||||
resp = self._test_preview(Location('i4x', 'edX', 'toy', 'vertical', 'vertical_test', None))
|
||||
resp = self._test_preview(Location('i4x', 'edX', 'toy', 'vertical', 'vertical_test', None), 'container_preview')
|
||||
# These are the data-ids of the xblocks contained in the vertical.
|
||||
# Ultimately, these must be converted to new locators.
|
||||
self.assertContains(resp, 'i4x://edX/toy/video/sample_video')
|
||||
@@ -492,7 +492,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
self.assertContains(resp, 'i4x://edX/toy/video/video_with_end_time')
|
||||
self.assertContains(resp, 'i4x://edX/toy/poll_question/T1_changemind_poll_foo_2')
|
||||
|
||||
def _test_preview(self, location):
|
||||
def _test_preview(self, location, view_name):
|
||||
""" Preview test case. """
|
||||
direct_store = modulestore('direct')
|
||||
_, course_items = import_from_xml(direct_store, 'common/test/data/', ['toy'])
|
||||
@@ -501,7 +501,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
locator = loc_mapper().translate_location(
|
||||
course_items[0].location.course_id, location, True, True
|
||||
)
|
||||
resp = self.client.get_fragment(locator.url_reverse('xblock', 'student_view'))
|
||||
resp = self.client.get_json(locator.url_reverse('xblock', view_name))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
# TODO: uncomment when preview no longer has locations being returned.
|
||||
# _test_no_locations(self, resp)
|
||||
|
||||
@@ -57,12 +57,6 @@ class AjaxEnabledTestClient(Client):
|
||||
"""
|
||||
return self.get(path, data or {}, follow, HTTP_ACCEPT="application/json", **extra)
|
||||
|
||||
def get_fragment(self, path, data=None, follow=False, **extra):
|
||||
"""
|
||||
Convenience method for client.get which sets the accept type to application/x-fragment+json
|
||||
"""
|
||||
return self.get(path, data or {}, follow, HTTP_ACCEPT="application/x-fragment+json", **extra)
|
||||
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_MODULESTORE)
|
||||
|
||||
@@ -6,7 +6,7 @@ from collections import defaultdict
|
||||
|
||||
from django.http import HttpResponseBadRequest, Http404
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.views.decorators.http import require_GET
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.conf import settings
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
@@ -28,6 +28,7 @@ from xmodule.x_module import prefer_xmodules
|
||||
from lms.lib.xblock.runtime import unquote_slashes
|
||||
|
||||
from contentstore.utils import get_lms_link_for_item, compute_unit_state, UnitState
|
||||
from contentstore.views.helpers import get_parent_xblock
|
||||
|
||||
from models.settings.course_grading import CourseGradingModel
|
||||
|
||||
@@ -37,6 +38,7 @@ __all__ = ['OPEN_ENDED_COMPONENT_TYPES',
|
||||
'ADVANCED_COMPONENT_POLICY_KEY',
|
||||
'subsection_handler',
|
||||
'unit_handler',
|
||||
'container_handler',
|
||||
'component_handler'
|
||||
]
|
||||
|
||||
@@ -65,7 +67,7 @@ ADVANCED_COMPONENT_CATEGORY = 'advanced'
|
||||
ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules'
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
@require_GET
|
||||
@login_required
|
||||
def subsection_handler(request, tag=None, package_id=None, branch=None, version_guid=None, block=None):
|
||||
"""
|
||||
@@ -89,17 +91,7 @@ def subsection_handler(request, tag=None, package_id=None, branch=None, version_
|
||||
if item.location.category != 'sequential':
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
parent_locs = modulestore().get_parent_locations(old_location, None)
|
||||
|
||||
# we're for now assuming a single parent
|
||||
if len(parent_locs) != 1:
|
||||
logging.error(
|
||||
'Multiple (or none) parents have been found for %s',
|
||||
unicode(locator)
|
||||
)
|
||||
|
||||
# this should blow up if we don't find any parents, which would be erroneous
|
||||
parent = modulestore().get_item(parent_locs[0])
|
||||
parent = get_parent_xblock(item)
|
||||
|
||||
# remove all metadata from the generic dictionary that is presented in a
|
||||
# more normalized UI. We only want to display the XBlocks fields, not
|
||||
@@ -154,7 +146,7 @@ def _load_mixed_class(category):
|
||||
return mixologist.mix(component_class)
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
@require_GET
|
||||
@login_required
|
||||
def unit_handler(request, tag=None, package_id=None, branch=None, version_guid=None, block=None):
|
||||
"""
|
||||
@@ -236,24 +228,19 @@ def unit_handler(request, tag=None, package_id=None, branch=None, version_guid=N
|
||||
course_advanced_keys
|
||||
)
|
||||
|
||||
components = [
|
||||
xblocks = item.get_children()
|
||||
locators = [
|
||||
loc_mapper().translate_location(
|
||||
course.location.course_id, component.location, False, True
|
||||
course.location.course_id, xblock.location, False, True
|
||||
)
|
||||
for component
|
||||
in item.get_children()
|
||||
for xblock in xblocks
|
||||
]
|
||||
|
||||
# TODO (cpennington): If we share units between courses,
|
||||
# this will need to change to check permissions correctly so as
|
||||
# to pick the correct parent subsection
|
||||
|
||||
containing_subsection_locs = modulestore().get_parent_locations(old_location, None)
|
||||
containing_subsection = modulestore().get_item(containing_subsection_locs[0])
|
||||
containing_section_locs = modulestore().get_parent_locations(
|
||||
containing_subsection.location, None
|
||||
)
|
||||
containing_section = modulestore().get_item(containing_section_locs[0])
|
||||
containing_subsection = get_parent_xblock(item)
|
||||
containing_section = get_parent_xblock(containing_subsection)
|
||||
|
||||
# cdodge hack. We're having trouble previewing drafts via jump_to redirect
|
||||
# so let's generate the link url here
|
||||
@@ -285,7 +272,7 @@ def unit_handler(request, tag=None, package_id=None, branch=None, version_guid=N
|
||||
'context_course': course,
|
||||
'unit': item,
|
||||
'unit_locator': locator,
|
||||
'components': components,
|
||||
'locators': locators,
|
||||
'component_templates': component_templates,
|
||||
'draft_preview_link': preview_lms_link,
|
||||
'published_preview_link': lms_link,
|
||||
@@ -306,6 +293,35 @@ def unit_handler(request, tag=None, package_id=None, branch=None, version_guid=N
|
||||
return HttpResponseBadRequest("Only supports html requests")
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@require_GET
|
||||
@login_required
|
||||
def container_handler(request, tag=None, package_id=None, branch=None, version_guid=None, block=None):
|
||||
"""
|
||||
The restful handler for container xblock requests.
|
||||
|
||||
GET
|
||||
html: returns the HTML page for editing a container
|
||||
json: not currently supported
|
||||
"""
|
||||
if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'):
|
||||
locator = BlockUsageLocator(package_id=package_id, branch=branch, version_guid=version_guid, block_id=block)
|
||||
try:
|
||||
old_location, course, xblock, __ = _get_item_in_course(request, locator)
|
||||
except ItemNotFoundError:
|
||||
return HttpResponseBadRequest()
|
||||
parent_xblock = get_parent_xblock(xblock)
|
||||
|
||||
return render_to_response('container.html', {
|
||||
'context_course': course,
|
||||
'xblock': xblock,
|
||||
'xblock_locator': locator,
|
||||
'parent_xblock': parent_xblock,
|
||||
})
|
||||
else:
|
||||
return HttpResponseBadRequest("Only supports html requests")
|
||||
|
||||
|
||||
@login_required
|
||||
def _get_item_in_course(request, locator):
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import logging
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from edxmako.shortcuts import render_to_string, render_to_response
|
||||
from xmodule.modulestore.django import loc_mapper, modulestore
|
||||
|
||||
__all__ = ['edge', 'event', 'landing']
|
||||
|
||||
@@ -35,3 +38,64 @@ def _xmodule_recurse(item, action):
|
||||
_xmodule_recurse(child, action)
|
||||
|
||||
action(item)
|
||||
|
||||
|
||||
def get_parent_xblock(xblock):
|
||||
"""
|
||||
Returns the xblock that is the parent of the specified xblock, or None if it has no parent.
|
||||
"""
|
||||
locator = xblock.location
|
||||
parent_locations = modulestore().get_parent_locations(locator, None)
|
||||
|
||||
if len(parent_locations) == 0:
|
||||
return None
|
||||
elif len(parent_locations) > 1:
|
||||
logging.error('Multiple parents have been found for %s', unicode(locator))
|
||||
return modulestore().get_item(parent_locations[0])
|
||||
|
||||
|
||||
def _xblock_has_studio_page(xblock):
|
||||
"""
|
||||
Returns true if the specified xblock has an associated Studio page. Most xblocks do
|
||||
not have their own page but are instead shown on the page of their parent. There
|
||||
are a few exceptions:
|
||||
1. Courses
|
||||
2. Verticals
|
||||
3. XBlocks with children, except for:
|
||||
- subsections (aka sequential blocks)
|
||||
- chapters
|
||||
"""
|
||||
category = xblock.category
|
||||
if category in ('course', 'vertical'):
|
||||
return True
|
||||
elif category in ('sequential', 'chapter'):
|
||||
return False
|
||||
elif xblock.has_children:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def xblock_studio_url(xblock, course=None):
|
||||
"""
|
||||
Returns the Studio editing URL for the specified xblock.
|
||||
"""
|
||||
if not _xblock_has_studio_page(xblock):
|
||||
return None
|
||||
category = xblock.category
|
||||
parent_xblock = get_parent_xblock(xblock)
|
||||
if parent_xblock:
|
||||
parent_category = parent_xblock.category
|
||||
else:
|
||||
parent_category = None
|
||||
if category == 'course':
|
||||
prefix = 'course'
|
||||
elif category == 'vertical' and parent_category == 'sequential':
|
||||
prefix = 'unit' # only show the unit page for verticals directly beneath a subsection
|
||||
else:
|
||||
prefix = 'container'
|
||||
course_id = None
|
||||
if course:
|
||||
course_id = course.location.course_id
|
||||
locator = loc_mapper().translate_location(course_id, xblock.location)
|
||||
return locator.url_reverse(prefix)
|
||||
|
||||
@@ -12,7 +12,7 @@ from xmodule_modifiers import wrap_xblock
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponseBadRequest, HttpResponse
|
||||
from django.http import HttpResponseBadRequest, HttpResponse, Http404
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
@@ -164,7 +164,6 @@ def xblock_handler(request, tag=None, package_id=None, branch=None, version_guid
|
||||
content_type="text/plain"
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@require_http_methods(("GET"))
|
||||
@login_required
|
||||
@@ -185,7 +184,7 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v
|
||||
|
||||
accept_header = request.META.get('HTTP_ACCEPT', 'application/json')
|
||||
|
||||
if 'application/x-fragment+json' in accept_header:
|
||||
if 'application/json' in accept_header:
|
||||
store = get_modulestore(old_location)
|
||||
component = store.get_item(old_location)
|
||||
|
||||
@@ -204,17 +203,46 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v
|
||||
fragment = Fragment(render_to_string('html_error.html', {'message': str(exc)}))
|
||||
|
||||
store.save_xmodule(component)
|
||||
|
||||
elif view_name == 'student_view':
|
||||
fragment = get_preview_fragment(request, component)
|
||||
fragment.content = render_to_string('component.html', {
|
||||
'preview': fragment.content,
|
||||
'label': component.display_name or component.scope_ids.block_type,
|
||||
|
||||
# Native XBlocks are responsible for persisting their own data,
|
||||
# so they are also responsible for providing save/cancel buttons.
|
||||
'show_save_cancel': isinstance(component, xmodule.x_module.XModuleDescriptor),
|
||||
elif view_name == 'student_view' and component.has_children:
|
||||
# For non-leaf xblocks on the unit page, show the special rendering
|
||||
# which links to the new container page.
|
||||
course_location = loc_mapper().translate_locator_to_location(locator, True)
|
||||
course = store.get_item(course_location)
|
||||
html = render_to_string('unit_container_xblock_component.html', {
|
||||
'course': course,
|
||||
'xblock': component,
|
||||
'locator': locator
|
||||
})
|
||||
return JsonResponse({
|
||||
'html': html,
|
||||
'resources': [],
|
||||
})
|
||||
elif view_name in ('student_view', 'container_preview'):
|
||||
is_container_view = (view_name == 'container_preview')
|
||||
|
||||
# Only show the new style HTML for the container view, i.e. for non-verticals
|
||||
# Note: this special case logic can be removed once the unit page is replaced
|
||||
# with the new container view.
|
||||
is_read_only_view = is_container_view
|
||||
context = {
|
||||
'container_view': is_container_view,
|
||||
'read_only': is_read_only_view,
|
||||
'root_xblock': component
|
||||
}
|
||||
|
||||
fragment = get_preview_fragment(request, component, context)
|
||||
# For old-style pages (such as unit and static pages), wrap the preview with
|
||||
# the component div. Note that the container view recursively adds headers
|
||||
# into the preview fragment, so we don't want to add another header here.
|
||||
if not is_container_view:
|
||||
fragment.content = render_to_string('component.html', {
|
||||
'preview': fragment.content,
|
||||
'label': component.display_name or component.scope_ids.block_type,
|
||||
|
||||
# Native XBlocks are responsible for persisting their own data,
|
||||
# so they are also responsible for providing save/cancel buttons.
|
||||
'show_save_cancel': isinstance(component, xmodule.x_module.XModuleDescriptor),
|
||||
})
|
||||
else:
|
||||
raise Http404
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from django.http import Http404, HttpResponseBadRequest
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from edxmako.shortcuts import render_to_string
|
||||
|
||||
from xmodule_modifiers import replace_static_urls, wrap_xblock
|
||||
from xmodule_modifiers import replace_static_urls, wrap_xblock, wrap_fragment
|
||||
from xmodule.error_module import ErrorDescriptor
|
||||
from xmodule.exceptions import NotFoundError, ProcessingError
|
||||
from xmodule.modulestore.django import modulestore, loc_mapper, ModuleI18nService
|
||||
@@ -108,6 +108,17 @@ def _preview_module_system(request, descriptor):
|
||||
course_id = course_location.course_id
|
||||
else:
|
||||
course_id = get_course_for_item(descriptor.location).location.course_id
|
||||
display_name_only = (descriptor.category == 'static_tab')
|
||||
|
||||
wrappers = [
|
||||
# This wrapper wraps the module in the template specified above
|
||||
partial(wrap_xblock, 'PreviewRuntime', display_name_only=display_name_only),
|
||||
|
||||
# This wrapper replaces urls in the output that start with /static
|
||||
# with the correct course-specific url for the static content
|
||||
partial(replace_static_urls, None, course_id=course_id),
|
||||
_studio_wrap_xblock,
|
||||
]
|
||||
|
||||
return PreviewModuleSystem(
|
||||
static_url=settings.STATIC_URL,
|
||||
@@ -125,14 +136,7 @@ def _preview_module_system(request, descriptor):
|
||||
anonymous_student_id='student',
|
||||
|
||||
# Set up functions to modify the fragment produced by student_view
|
||||
wrappers=(
|
||||
# This wrapper wraps the module in the template specified above
|
||||
partial(wrap_xblock, 'PreviewRuntime', display_name_only=descriptor.category == 'static_tab'),
|
||||
|
||||
# This wrapper replaces urls in the output that start with /static
|
||||
# with the correct course-specific url for the static content
|
||||
partial(replace_static_urls, None, course_id=course_id),
|
||||
),
|
||||
wrappers=wrappers,
|
||||
error_descriptor_class=ErrorDescriptor,
|
||||
# get_user_role accepts a location or a CourseLocator.
|
||||
# If descriptor.location is a CourseLocator, course_id is unused.
|
||||
@@ -159,14 +163,38 @@ def _load_preview_module(request, descriptor):
|
||||
return descriptor
|
||||
|
||||
|
||||
def get_preview_fragment(request, descriptor):
|
||||
# pylint: disable=unused-argument
|
||||
def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
|
||||
"""
|
||||
Wraps the results of rendering an XBlock view in a div which adds a header and Studio action buttons.
|
||||
"""
|
||||
# Only add the Studio wrapper when on the container page. The unit page will remain as is for now.
|
||||
if context.get('container_view', None) and view == 'student_view':
|
||||
locator = loc_mapper().translate_location(xblock.course_id, xblock.location)
|
||||
template_context = {
|
||||
'xblock_context': context,
|
||||
'xblock': xblock,
|
||||
'locator': locator,
|
||||
'content': frag.content,
|
||||
}
|
||||
if xblock.category == 'vertical':
|
||||
template = 'studio_vertical_wrapper.html'
|
||||
else:
|
||||
template = 'studio_xblock_wrapper.html'
|
||||
html = render_to_string(template, template_context)
|
||||
frag = wrap_fragment(frag, html)
|
||||
return frag
|
||||
|
||||
|
||||
def get_preview_fragment(request, descriptor, context):
|
||||
"""
|
||||
Returns the HTML returned by the XModule's student_view,
|
||||
specified by the descriptor and idx.
|
||||
"""
|
||||
module = _load_preview_module(request, descriptor)
|
||||
|
||||
try:
|
||||
fragment = module.render("student_view")
|
||||
fragment = module.render("student_view", context)
|
||||
except Exception as exc: # pylint: disable=W0703
|
||||
log.warning("Unable to render student_view for %r", module, exc_info=True)
|
||||
fragment = Fragment(render_to_string('html_error.html', {'message': str(exc)}))
|
||||
|
||||
33
cms/djangoapps/contentstore/views/tests/test_container.py
Normal file
33
cms/djangoapps/contentstore/views/tests/test_container.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Unit tests for the container view.
|
||||
"""
|
||||
|
||||
from contentstore.tests.utils import CourseTestCase
|
||||
from contentstore.views.helpers import xblock_studio_url
|
||||
from xmodule.modulestore.tests.factories import ItemFactory
|
||||
|
||||
|
||||
class ContainerViewTestCase(CourseTestCase):
|
||||
"""
|
||||
Unit tests for the container view.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(ContainerViewTestCase, self).setUp()
|
||||
self.chapter = ItemFactory.create(parent_location=self.course.location,
|
||||
category='chapter', display_name="Week 1")
|
||||
self.sequential = ItemFactory.create(parent_location=self.chapter.location,
|
||||
category='sequential', display_name="Lesson 1")
|
||||
self.vertical = ItemFactory.create(parent_location=self.sequential.location,
|
||||
category='vertical', display_name='Unit')
|
||||
self.child_vertical = ItemFactory.create(parent_location=self.vertical.location,
|
||||
category='vertical', display_name='Child Vertical')
|
||||
self.video = ItemFactory.create(parent_location=self.child_vertical.location,
|
||||
category="video", display_name="My Video")
|
||||
|
||||
def test_container_html(self):
|
||||
url = xblock_studio_url(self.child_vertical)
|
||||
resp = self.client.get_html(url)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
html = resp.content
|
||||
self.assertIn('<section class="wrapper-xblock level-page" data-locator="MITx.999.Robot_Super_Course/branch/published/block/Child_Vertical"/>', html)
|
||||
44
cms/djangoapps/contentstore/views/tests/test_helpers.py
Normal file
44
cms/djangoapps/contentstore/views/tests/test_helpers.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Unit tests for helpers.py.
|
||||
"""
|
||||
|
||||
from contentstore.tests.utils import CourseTestCase
|
||||
from contentstore.views.helpers import xblock_studio_url
|
||||
from xmodule.modulestore.tests.factories import ItemFactory
|
||||
|
||||
|
||||
class HelpersTestCase(CourseTestCase):
|
||||
"""
|
||||
Unit tests for helpers.py.
|
||||
"""
|
||||
def test_xblock_studio_url(self):
|
||||
# Verify course URL
|
||||
self.assertEqual(xblock_studio_url(self.course),
|
||||
u'/course/MITx.999.Robot_Super_Course/branch/published/block/Robot_Super_Course')
|
||||
|
||||
# Verify chapter URL
|
||||
chapter = ItemFactory.create(parent_location=self.course.location, category='chapter',
|
||||
display_name="Week 1")
|
||||
self.assertIsNone(xblock_studio_url(chapter))
|
||||
|
||||
# Verify lesson URL
|
||||
sequential = ItemFactory.create(parent_location=chapter.location, category='sequential',
|
||||
display_name="Lesson 1")
|
||||
self.assertIsNone(xblock_studio_url(sequential))
|
||||
|
||||
# Verify vertical URL
|
||||
vertical = ItemFactory.create(parent_location=sequential.location, category='vertical',
|
||||
display_name='Unit')
|
||||
self.assertEqual(xblock_studio_url(vertical),
|
||||
u'/unit/MITx.999.Robot_Super_Course/branch/published/block/Unit')
|
||||
|
||||
# Verify child vertical URL
|
||||
child_vertical = ItemFactory.create(parent_location=vertical.location, category='vertical',
|
||||
display_name='Child Vertical')
|
||||
self.assertEqual(xblock_studio_url(child_vertical),
|
||||
u'/container/MITx.999.Robot_Super_Course/branch/published/block/Child_Vertical')
|
||||
|
||||
# Verify video URL
|
||||
video = ItemFactory.create(parent_location=child_vertical.location, category="video",
|
||||
display_name="My Video")
|
||||
self.assertIsNone(xblock_studio_url(video))
|
||||
@@ -70,6 +70,28 @@ class ItemTest(CourseTestCase):
|
||||
class GetItem(ItemTest):
|
||||
"""Tests for '/xblock' GET url."""
|
||||
|
||||
def _create_vertical(self, parent_locator=None):
|
||||
"""
|
||||
Creates a vertical, returning its locator.
|
||||
"""
|
||||
resp = self.create_xblock(category='vertical', parent_locator=parent_locator)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
return self.response_locator(resp)
|
||||
|
||||
def _get_container_preview(self, locator):
|
||||
"""
|
||||
Returns the HTML and resources required for the xblock at the specified locator
|
||||
"""
|
||||
preview_url = '/xblock/{locator}/container_preview'.format(locator=locator)
|
||||
resp = self.client.get(preview_url, HTTP_ACCEPT='application/json')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
resp_content = json.loads(resp.content)
|
||||
html = resp_content['html']
|
||||
self.assertTrue(html)
|
||||
resources = resp_content['resources']
|
||||
self.assertIsNotNone(resources)
|
||||
return html, resources
|
||||
|
||||
def test_get_vertical(self):
|
||||
# Add a vertical
|
||||
resp = self.create_xblock(category='vertical')
|
||||
@@ -80,6 +102,36 @@ class GetItem(ItemTest):
|
||||
resp = self.client.get('/xblock/' + resp_content['locator'])
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
def test_get_empty_container_fragment(self):
|
||||
root_locator = self._create_vertical()
|
||||
html, __ = self._get_container_preview(root_locator)
|
||||
|
||||
# Verify that the Studio wrapper is not added
|
||||
self.assertNotIn('wrapper-xblock', html)
|
||||
|
||||
# Verify that the header and article tags are still added
|
||||
self.assertIn('<header class="xblock-header">', html)
|
||||
self.assertIn('<article class="xblock-render">', html)
|
||||
|
||||
def test_get_container_fragment(self):
|
||||
root_locator = self._create_vertical()
|
||||
|
||||
# Add a problem beneath a child vertical
|
||||
child_vertical_locator = self._create_vertical(parent_locator=root_locator)
|
||||
resp = self.create_xblock(parent_locator=child_vertical_locator, category='problem', boilerplate='multiplechoice.yaml')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# Get the preview HTML
|
||||
html, __ = self._get_container_preview(root_locator)
|
||||
|
||||
# Verify that the Studio nesting wrapper has been added
|
||||
self.assertIn('level-nesting', html)
|
||||
self.assertIn('<header class="xblock-header">', html)
|
||||
self.assertIn('<article class="xblock-render">', html)
|
||||
|
||||
# Verify that the Studio element wrapper has been added
|
||||
self.assertIn('level-element', html)
|
||||
|
||||
|
||||
class DeleteItem(ItemTest):
|
||||
"""Tests for '/xblock' DELETE url."""
|
||||
@@ -565,11 +617,12 @@ class TestEditItem(ItemTest):
|
||||
self.assertNotEqual(draft.data, published.data)
|
||||
|
||||
# Get problem by 'xblock_handler'
|
||||
resp = self.client.get('/xblock/' + self.problem_locator + '/student_view', HTTP_ACCEPT='application/x-fragment+json')
|
||||
view_url = '/xblock/{locator}/student_view'.format(locator=self.problem_locator)
|
||||
resp = self.client.get(view_url, HTTP_ACCEPT='application/json')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# Activate the editing view
|
||||
resp = self.client.get('/xblock/' + self.problem_locator + '/studio_view', HTTP_ACCEPT='application/x-fragment+json')
|
||||
resp = self.client.get(view_url, HTTP_ACCEPT='application/json')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# Both published and draft content should still be different
|
||||
@@ -647,8 +700,8 @@ class TestNativeXBlock(ItemTest):
|
||||
native_loc = json.loads(resp.content)['locator']
|
||||
|
||||
# Render the XBlock
|
||||
resp_content = json.loads(resp.content)
|
||||
resp = self.client.get('/xblock/' + native_loc + '/student_view', HTTP_ACCEPT='application/x-fragment+json')
|
||||
view_url = '/xblock/{locator}/student_view'.format(locator=native_loc)
|
||||
resp = self.client.get(view_url, HTTP_ACCEPT='application/json')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# Check that the save and cancel buttons are hidden for native XBlocks,
|
||||
|
||||
@@ -45,7 +45,7 @@ class GetPreviewHtmlTestCase(TestCase):
|
||||
|
||||
# Must call get_preview_fragment directly, as going through xblock RESTful API will attempt
|
||||
# to use item.location as a Location.
|
||||
html = get_preview_fragment(request, html).content
|
||||
html = get_preview_fragment(request, html, {}).content
|
||||
# Verify student view html is returned, and there are no old locations in it.
|
||||
self.assertRegexpMatches(
|
||||
html,
|
||||
|
||||
@@ -216,6 +216,7 @@ define([
|
||||
"js/spec/views/paging_spec",
|
||||
|
||||
"js/spec/views/unit_spec"
|
||||
"js/spec/views/xblock_spec"
|
||||
|
||||
# these tests are run separate in the cms-squire suite, due to process
|
||||
# isolation issues with Squire.js
|
||||
|
||||
@@ -50,7 +50,7 @@ define ["jquery", "coffee/src/views/module_edit", "js/models/module_info", "xmod
|
||||
)
|
||||
|
||||
it "renders the module editor", ->
|
||||
expect(@moduleEdit.render).toHaveBeenCalled()
|
||||
expect(ModuleEdit.prototype.render).toHaveBeenCalled()
|
||||
|
||||
describe "render", ->
|
||||
beforeEach ->
|
||||
@@ -80,7 +80,7 @@ define ["jquery", "coffee/src/views/module_edit", "js/models/module_info", "xmod
|
||||
url: "/xblock/#{@moduleEdit.model.id}/student_view"
|
||||
type: "GET"
|
||||
headers:
|
||||
Accept: 'application/x-fragment+json'
|
||||
Accept: 'application/json'
|
||||
success: jasmine.any(Function)
|
||||
)
|
||||
|
||||
@@ -88,7 +88,7 @@ define ["jquery", "coffee/src/views/module_edit", "js/models/module_info", "xmod
|
||||
url: "/xblock/#{@moduleEdit.model.id}/studio_view"
|
||||
type: "GET"
|
||||
headers:
|
||||
Accept: 'application/x-fragment+json'
|
||||
Accept: 'application/json'
|
||||
success: jasmine.any(Function)
|
||||
)
|
||||
expect(@moduleEdit.loadDisplay).toHaveBeenCalled()
|
||||
@@ -100,7 +100,7 @@ define ["jquery", "coffee/src/views/module_edit", "js/models/module_info", "xmod
|
||||
url: "/xblock/#{@moduleEdit.model.id}/studio_view"
|
||||
type: "GET"
|
||||
headers:
|
||||
Accept: 'application/x-fragment+json'
|
||||
Accept: 'application/json'
|
||||
success: jasmine.any(Function)
|
||||
)
|
||||
expect(@moduleEdit.loadEdit).not.toHaveBeenCalled()
|
||||
@@ -123,7 +123,7 @@ define ["jquery", "coffee/src/views/module_edit", "js/models/module_info", "xmod
|
||||
url: "/xblock/#{@moduleEdit.model.id}/studio_view"
|
||||
type: "GET"
|
||||
headers:
|
||||
Accept: 'application/x-fragment+json'
|
||||
Accept: 'application/json'
|
||||
success: jasmine.any(Function)
|
||||
)
|
||||
expect(@moduleEdit.loadEdit).toHaveBeenCalled()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
define ["backbone", "jquery", "underscore", "gettext", "xblock/runtime.v1",
|
||||
"js/views/feedback_notification", "js/views/metadata", "js/collections/metadata"
|
||||
"js/utils/modal", "jquery.inputnumber", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
|
||||
(Backbone, $, _, gettext, XBlock, NotificationView, MetadataView, MetadataCollection, ModalUtils) ->
|
||||
class ModuleEdit extends Backbone.View
|
||||
define ["jquery", "underscore", "gettext", "xblock/runtime.v1",
|
||||
"js/views/xblock", "js/views/feedback_notification", "js/views/metadata", "js/collections/metadata"
|
||||
"js/utils/modal", "jquery.inputnumber"],
|
||||
($, _, gettext, XBlock, XBlockView, NotificationView, MetadataView, MetadataCollection, ModalUtils) ->
|
||||
class ModuleEdit extends XBlockView
|
||||
tagName: 'li'
|
||||
className: 'component'
|
||||
editorMode: 'editor-mode'
|
||||
@@ -79,31 +79,9 @@ define ["backbone", "jquery", "underscore", "gettext", "xblock/runtime.v1",
|
||||
url: "#{decodeURIComponent(@model.url())}/#{viewName}"
|
||||
type: 'GET'
|
||||
headers:
|
||||
Accept: 'application/x-fragment+json'
|
||||
success: (data) =>
|
||||
$(target).html(data.html)
|
||||
|
||||
for value in data.resources
|
||||
do (value) =>
|
||||
hash = value[0]
|
||||
if not window.loadedXBlockResources?
|
||||
window.loadedXBlockResources = []
|
||||
|
||||
if hash not in window.loadedXBlockResources
|
||||
resource = value[1]
|
||||
switch resource.mimetype
|
||||
when "text/css"
|
||||
switch resource.kind
|
||||
when "text" then $('head').append("<style type='text/css'>#{resource.data}</style>")
|
||||
when "url" then $('head').append("<link rel='stylesheet' href='#{resource.data}' type='text/css'>")
|
||||
when "application/javascript"
|
||||
switch resource.kind
|
||||
when "text" then $('head').append("<script>#{resource.data}</script>")
|
||||
when "url" then $.getScript(resource.data)
|
||||
when "text/html"
|
||||
switch resource.placement
|
||||
when "head" then $('head').append(resource.data)
|
||||
window.loadedXBlockResources.push(hash)
|
||||
Accept: 'application/json'
|
||||
success: (fragment) =>
|
||||
@renderXBlockFragment(fragment, target, viewName)
|
||||
callback()
|
||||
)
|
||||
|
||||
|
||||
16
cms/static/js/models/xblock_info.js
Normal file
16
cms/static/js/models/xblock_info.js
Normal file
@@ -0,0 +1,16 @@
|
||||
define(["backbone", "js/utils/module"], function(Backbone, ModuleUtils) {
|
||||
var XBlockInfo = Backbone.Model.extend({
|
||||
|
||||
urlRoot: ModuleUtils.urlRoot,
|
||||
|
||||
defaults: {
|
||||
"id": null,
|
||||
"display_name": null,
|
||||
"category": null,
|
||||
"is_draft": null,
|
||||
"is_container": null,
|
||||
"children": []
|
||||
}
|
||||
});
|
||||
return XBlockInfo;
|
||||
});
|
||||
@@ -1,50 +1,80 @@
|
||||
define(
|
||||
[
|
||||
"jquery", "underscore",
|
||||
"js/views/baseview",
|
||||
"js/utils/handle_iframe_binding",
|
||||
"sinon"
|
||||
],
|
||||
function ($, _, BaseView, IframeBinding, sinon) {
|
||||
define(["jquery", "underscore", "js/views/baseview", "js/utils/handle_iframe_binding", "sinon"],
|
||||
function ($, _, BaseView, IframeBinding, sinon) {
|
||||
|
||||
describe("BaseView check", function () {
|
||||
var baseView;
|
||||
var iframeBinding_spy;
|
||||
describe("BaseView", function() {
|
||||
var baseViewPrototype;
|
||||
|
||||
beforeEach(function () {
|
||||
iframeBinding_spy = sinon.spy(IframeBinding, "iframeBinding");
|
||||
baseView = BaseView.prototype;
|
||||
describe("BaseView rendering", function () {
|
||||
var iframeBinding_spy;
|
||||
|
||||
spyOn(baseView, 'initialize');
|
||||
spyOn(baseView, 'beforeRender');
|
||||
spyOn(baseView, 'render');
|
||||
spyOn(baseView, 'afterRender').andCallThrough();
|
||||
});
|
||||
beforeEach(function () {
|
||||
baseViewPrototype = BaseView.prototype;
|
||||
iframeBinding_spy = sinon.spy(IframeBinding, "iframeBinding");
|
||||
|
||||
afterEach(function () {
|
||||
iframeBinding_spy.restore();
|
||||
});
|
||||
spyOn(baseViewPrototype, 'initialize');
|
||||
spyOn(baseViewPrototype, 'beforeRender');
|
||||
spyOn(baseViewPrototype, 'render').andCallThrough();
|
||||
spyOn(baseViewPrototype, 'afterRender').andCallThrough();
|
||||
});
|
||||
|
||||
it('calls before and after render functions when render of baseview is called', function () {
|
||||
var baseview_temp = new BaseView()
|
||||
baseview_temp.render();
|
||||
afterEach(function () {
|
||||
iframeBinding_spy.restore();
|
||||
});
|
||||
|
||||
expect(baseView.initialize).toHaveBeenCalled();
|
||||
expect(baseView.beforeRender).toHaveBeenCalled();
|
||||
expect(baseView.render).toHaveBeenCalled();
|
||||
expect(baseView.afterRender).toHaveBeenCalled();
|
||||
});
|
||||
it('calls before and after render functions when render of baseview is called', function () {
|
||||
var baseView = new BaseView();
|
||||
baseView.render();
|
||||
|
||||
it('calls iframeBinding function when afterRender of baseview is called', function () {
|
||||
var baseview_temp = new BaseView()
|
||||
baseview_temp.render();
|
||||
expect(baseView.afterRender).toHaveBeenCalled();
|
||||
expect(iframeBinding_spy.called).toEqual(true);
|
||||
expect(baseViewPrototype.initialize).toHaveBeenCalled();
|
||||
expect(baseViewPrototype.beforeRender).toHaveBeenCalled();
|
||||
expect(baseViewPrototype.render).toHaveBeenCalled();
|
||||
expect(baseViewPrototype.afterRender).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
//check calls count of iframeBinding function
|
||||
expect(iframeBinding_spy.callCount).toBe(1);
|
||||
IframeBinding.iframeBinding();
|
||||
expect(iframeBinding_spy.callCount).toBe(2);
|
||||
it('calls iframeBinding function when afterRender of baseview is called', function () {
|
||||
var baseView = new BaseView();
|
||||
baseView.render();
|
||||
expect(baseViewPrototype.afterRender).toHaveBeenCalled();
|
||||
expect(iframeBinding_spy.called).toEqual(true);
|
||||
|
||||
//check calls count of iframeBinding function
|
||||
expect(iframeBinding_spy.callCount).toBe(1);
|
||||
IframeBinding.iframeBinding();
|
||||
expect(iframeBinding_spy.callCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Expand/Collapse", function () {
|
||||
var view, MockCollapsibleViewClass;
|
||||
|
||||
MockCollapsibleViewClass = BaseView.extend({
|
||||
initialize: function() {
|
||||
this.viewHtml = readFixtures('mock/mock-collapsible-view.underscore');
|
||||
},
|
||||
|
||||
render: function() {
|
||||
this.$el.html(this.viewHtml);
|
||||
}
|
||||
});
|
||||
|
||||
it('hides a collapsible node when clicking on the toggle link', function () {
|
||||
view = new MockCollapsibleViewClass();
|
||||
view.render();
|
||||
view.$('.ui-toggle-expansion').click();
|
||||
expect(view.$('.expand-collapse')).toHaveClass('expand');
|
||||
expect(view.$('.expand-collapse')).not.toHaveClass('collapse');
|
||||
expect(view.$('.is-collapsible')).toHaveClass('collapsed');
|
||||
});
|
||||
|
||||
it('expands a collapsible node when clicking twice on the toggle link', function () {
|
||||
view = new MockCollapsibleViewClass();
|
||||
view.render();
|
||||
view.$('.ui-toggle-expansion').click();
|
||||
view.$('.ui-toggle-expansion').click();
|
||||
expect(view.$('.expand-collapse')).toHaveClass('collapse');
|
||||
expect(view.$('.expand-collapse')).not.toHaveClass('expand');
|
||||
expect(view.$('.is-collapsible')).not.toHaveClass('collapsed');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
92
cms/static/js/spec/views/xblock_spec.js
Normal file
92
cms/static/js/spec/views/xblock_spec.js
Normal file
@@ -0,0 +1,92 @@
|
||||
define([ "jquery", "js/spec/create_sinon", "URI", "js/views/xblock", "js/models/xblock_info",
|
||||
"xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
|
||||
function ($, create_sinon, URI, XBlockView, XBlockInfo) {
|
||||
|
||||
describe("XBlockView", function() {
|
||||
var model, xblockView, mockXBlockHtml, respondWithMockXBlockFragment;
|
||||
|
||||
beforeEach(function () {
|
||||
model = new XBlockInfo({
|
||||
id: 'testCourse/branch/published/block/verticalFFF',
|
||||
display_name: 'Test Unit',
|
||||
category: 'vertical'
|
||||
});
|
||||
xblockView = new XBlockView({
|
||||
model: model
|
||||
});
|
||||
});
|
||||
|
||||
mockXBlockHtml = readFixtures('mock/mock-xblock.underscore');
|
||||
|
||||
respondWithMockXBlockFragment = function(requests, response) {
|
||||
var requestIndex = requests.length - 1;
|
||||
create_sinon.respondWithJson(requests, response, requestIndex);
|
||||
};
|
||||
|
||||
it('can render a nested xblock', function() {
|
||||
var requests = create_sinon.requests(this);
|
||||
xblockView.render();
|
||||
respondWithMockXBlockFragment(requests, {
|
||||
html: mockXBlockHtml,
|
||||
"resources": []
|
||||
});
|
||||
|
||||
expect(xblockView.$el.select('.xblock-header')).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("XBlock rendering", function() {
|
||||
var postXBlockRequest;
|
||||
|
||||
postXBlockRequest = function(requests, resources) {
|
||||
$.ajax({
|
||||
url: "test_url",
|
||||
type: 'GET',
|
||||
success: function(fragment) {
|
||||
xblockView.renderXBlockFragment(fragment, this.$el);
|
||||
}
|
||||
});
|
||||
respondWithMockXBlockFragment(requests, {
|
||||
html: mockXBlockHtml,
|
||||
resources: resources
|
||||
});
|
||||
expect(xblockView.$el.select('.xblock-header')).toBeTruthy();
|
||||
};
|
||||
|
||||
it('can render an xblock with no CSS or JavaScript', function() {
|
||||
var requests = create_sinon.requests(this);
|
||||
postXBlockRequest(requests, []);
|
||||
});
|
||||
|
||||
it('can render an xblock with required CSS', function() {
|
||||
var requests = create_sinon.requests(this),
|
||||
mockCssText = "// Just a comment",
|
||||
mockCssUrl = "mock.css",
|
||||
headHtml;
|
||||
postXBlockRequest(requests, [
|
||||
["hash1", { mimetype: "text/css", kind: "text", data: mockCssText }],
|
||||
["hash2", { mimetype: "text/css", kind: "url", data: mockCssUrl }]
|
||||
]);
|
||||
headHtml = $('head').html();
|
||||
expect(headHtml).toContain(mockCssText);
|
||||
expect(headHtml).toContain(mockCssUrl);
|
||||
});
|
||||
|
||||
it('can render an xblock with required JavaScript', function() {
|
||||
var requests = create_sinon.requests(this);
|
||||
postXBlockRequest(requests, [
|
||||
["hash3", { mimetype: "application/javascript", kind: "text", data: "window.test = 100;" }]
|
||||
]);
|
||||
expect(window.test).toBe(100);
|
||||
});
|
||||
|
||||
it('can render an xblock with required HTML', function() {
|
||||
var requests = create_sinon.requests(this),
|
||||
mockHeadTag = "<title>Test Title</title>";
|
||||
postXBlockRequest(requests, [
|
||||
["hash4", { mimetype: "text/html", placement: "head", data: mockHeadTag }]
|
||||
]);
|
||||
expect($('head').html()).toContain(mockHeadTag);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,44 +1,53 @@
|
||||
define(
|
||||
[
|
||||
'jquery',
|
||||
'underscore',
|
||||
'backbone',
|
||||
"js/utils/handle_iframe_binding"
|
||||
],
|
||||
define(["jquery", "underscore", "backbone", "js/utils/handle_iframe_binding"],
|
||||
function ($, _, Backbone, IframeUtils) {
|
||||
/* This view is extended from backbone with custom functions 'beforeRender' and 'afterRender'. It allows other
|
||||
views, which extend from it to access these custom functions. 'afterRender' function of BaseView calls a utility
|
||||
function 'iframeBinding' which modifies iframe src urls on a page so that they are rendered as part of the DOM.
|
||||
Other common functions which need to be run before/after can also be added here.
|
||||
*/
|
||||
/*
|
||||
This view is extended from backbone to provide useful functionality for all Studio views.
|
||||
This functionality includes:
|
||||
- automatic expand and collapse of elements with the 'ui-toggle-expansion' class specified
|
||||
- additional control of rendering by overriding 'beforeRender' or 'afterRender'
|
||||
|
||||
var BaseView = Backbone.View.extend({
|
||||
//override the constructor function
|
||||
constructor: function(options) {
|
||||
_.bindAll(this, 'beforeRender', 'render', 'afterRender');
|
||||
var _this = this;
|
||||
this.render = _.wrap(this.render, function (render) {
|
||||
_this.beforeRender();
|
||||
render();
|
||||
_this.afterRender();
|
||||
return _this;
|
||||
});
|
||||
Note: the default 'afterRender' function calls a utility function 'iframeBinding' which modifies
|
||||
iframe src urls on a page so that they are rendered as part of the DOM.
|
||||
*/
|
||||
|
||||
//call Backbone's own constructor
|
||||
Backbone.View.prototype.constructor.apply(this, arguments);
|
||||
},
|
||||
var BaseView = Backbone.View.extend({
|
||||
events: {
|
||||
"click .ui-toggle-expansion": "toggleExpandCollapse"
|
||||
},
|
||||
|
||||
beforeRender: function () {
|
||||
},
|
||||
//override the constructor function
|
||||
constructor: function(options) {
|
||||
_.bindAll(this, 'beforeRender', 'render', 'afterRender');
|
||||
var _this = this;
|
||||
this.render = _.wrap(this.render, function (render) {
|
||||
_this.beforeRender();
|
||||
render();
|
||||
_this.afterRender();
|
||||
return _this;
|
||||
});
|
||||
|
||||
render: function () {
|
||||
return this;
|
||||
},
|
||||
//call Backbone's own constructor
|
||||
Backbone.View.prototype.constructor.apply(this, arguments);
|
||||
},
|
||||
|
||||
afterRender: function () {
|
||||
IframeUtils.iframeBinding(this);
|
||||
}
|
||||
beforeRender: function() {
|
||||
},
|
||||
|
||||
render: function() {
|
||||
return this;
|
||||
},
|
||||
|
||||
afterRender: function() {
|
||||
IframeUtils.iframeBinding(this);
|
||||
},
|
||||
|
||||
toggleExpandCollapse: function(event) {
|
||||
var target = $(event.target);
|
||||
event.preventDefault();
|
||||
target.closest('.expand-collapse').toggleClass('expand').toggleClass('collapse');
|
||||
target.closest('.is-collapsible, .window').toggleClass('collapsed');
|
||||
}
|
||||
});
|
||||
|
||||
return BaseView;
|
||||
});
|
||||
|
||||
return BaseView;
|
||||
});
|
||||
86
cms/static/js/views/xblock.js
Normal file
86
cms/static/js/views/xblock.js
Normal file
@@ -0,0 +1,86 @@
|
||||
define(["jquery", "underscore", "js/views/baseview", "xblock/runtime.v1"],
|
||||
function ($, _, BaseView, XBlock) {
|
||||
|
||||
var XBlockView = BaseView.extend({
|
||||
// takes XBlockInfo as a model
|
||||
|
||||
initialize: function() {
|
||||
BaseView.prototype.initialize.call(this);
|
||||
this.view = this.options.view;
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var self = this,
|
||||
view = this.view;
|
||||
return $.ajax({
|
||||
url: decodeURIComponent(this.model.url()) + "/" + view,
|
||||
type: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
success: function(fragment) {
|
||||
var wrapper = self.$el,
|
||||
xblock;
|
||||
self.renderXBlockFragment(fragment, wrapper);
|
||||
xblock = self.$('.xblock').first();
|
||||
XBlock.initializeBlock(xblock);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Renders an xblock fragment into the specifed element. The fragment has two attributes:
|
||||
* html: the HTML to be rendered
|
||||
* resources: any JavaScript or CSS resources that the HTML depends upon
|
||||
* @param fragment The fragment returned from the xblock_handler
|
||||
* @param element The element into which to render the fragment (defaults to this.$el)
|
||||
*/
|
||||
renderXBlockFragment: function(fragment, element) {
|
||||
var applyResource, i, len, resources, resource;
|
||||
if (!element) {
|
||||
element = this.$el;
|
||||
}
|
||||
|
||||
applyResource = function(value) {
|
||||
var hash, resource, head;
|
||||
hash = value[0];
|
||||
if (!window.loadedXBlockResources) {
|
||||
window.loadedXBlockResources = [];
|
||||
}
|
||||
if (_.indexOf(window.loadedXBlockResources, hash) < 0) {
|
||||
resource = value[1];
|
||||
head = $('head');
|
||||
if (resource.mimetype === "text/css") {
|
||||
if (resource.kind === "text") {
|
||||
head.append("<style type='text/css'>" + resource.data + "</style>");
|
||||
} else if (resource.kind === "url") {
|
||||
head.append("<link rel='stylesheet' href='" + resource.data + "' type='text/css'>");
|
||||
}
|
||||
} else if (resource.mimetype === "application/javascript") {
|
||||
if (resource.kind === "text") {
|
||||
head.append("<script>" + resource.data + "</script>");
|
||||
} else if (resource.kind === "url") {
|
||||
$.getScript(resource.data);
|
||||
}
|
||||
} else if (resource.mimetype === "text/html") {
|
||||
if (resource.placement === "head") {
|
||||
head.append(resource.data);
|
||||
}
|
||||
}
|
||||
window.loadedXBlockResources.push(hash);
|
||||
}
|
||||
};
|
||||
|
||||
element.html(fragment.html);
|
||||
resources = fragment.resources;
|
||||
for (i = 0, len = resources.length; i < len; i++) {
|
||||
resource = resources[i];
|
||||
applyResource(resource);
|
||||
}
|
||||
return this.delegateEvents();
|
||||
}
|
||||
});
|
||||
|
||||
return XBlockView;
|
||||
}); // end define();
|
||||
@@ -214,7 +214,6 @@
|
||||
display: inline-block;
|
||||
|
||||
.action-button {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
border-radius: 3px;
|
||||
padding: ($baseline/4) ($baseline/2);
|
||||
height: ($baseline*1.5);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}
|
||||
|
||||
[class^="icon-"] {
|
||||
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.icon-inline {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
// extends - UI archetypes - xblock rendering
|
||||
%wrap-xblock {
|
||||
margin: ($baseline/2);
|
||||
margin: $baseline;
|
||||
border: 1px solid $gray-l4;
|
||||
border-radius: ($baseline/5);
|
||||
background: $white;
|
||||
@@ -57,6 +57,10 @@
|
||||
// UI: xblock is collapsible
|
||||
.wrapper-xblock.is-collapsible {
|
||||
|
||||
[class^="icon-"] {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.expand-collapse {
|
||||
@extend %expand-collapse;
|
||||
margin: 0 ($baseline/4);
|
||||
@@ -74,4 +78,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
// For containers rendered at the element level, the container is rendered in a way that allows the user to navigate to a separate container page for that container making its children populate the nesting and element levels.
|
||||
|
||||
// ====================
|
||||
|
||||
// UI: container page view
|
||||
body.view-container {
|
||||
|
||||
@@ -64,11 +66,21 @@ body.view-container .content-primary{
|
||||
border-bottom: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.xblock-render {
|
||||
margin: 0 $baseline $baseline $baseline;
|
||||
}
|
||||
|
||||
// STATE: nesting level xblock is collapsed
|
||||
&.collapsed {
|
||||
padding-bottom: 0;
|
||||
background-color: $gray-l7;
|
||||
box-shadow: 0 0 1px $shadow-d2 inset;
|
||||
}
|
||||
}
|
||||
|
||||
// CASE: element level xblock rendering
|
||||
&.level-element {
|
||||
margin: 0 ($baseline*2) $baseline ($baseline*2);
|
||||
box-shadow: none;
|
||||
|
||||
&:hover {
|
||||
@@ -77,6 +89,7 @@ body.view-container .content-primary{
|
||||
}
|
||||
|
||||
.xblock-header {
|
||||
display: flex;
|
||||
margin-bottom: 0;
|
||||
border-bottom: 1px solid $gray-l4;
|
||||
background-color: $gray-l6;
|
||||
@@ -103,3 +116,21 @@ body.view-container .content-primary{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// UI: xblocks - internal styling
|
||||
|
||||
// In order to ensure visual consistency across the unit and container pages, certain styles need to be applied to render on the container page until they are also cleaned up and applied differently on the unit page.
|
||||
.wrapper-xblock {
|
||||
|
||||
// UI: xblocks - internal headings for problems and video components
|
||||
h2 {
|
||||
margin: 30px 40px 30px 0;
|
||||
color: #646464;
|
||||
font-size: 19px;
|
||||
font-weight: 300;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -950,7 +950,6 @@ body.course.unit,.view-unit {
|
||||
body.unit {
|
||||
|
||||
.component {
|
||||
padding-top: 30px;
|
||||
|
||||
|
||||
.wrapper-component-action-header {
|
||||
@@ -1003,7 +1002,6 @@ body.unit {
|
||||
margin: ($baseline/4) 0 ($baseline/4) ($baseline/4);
|
||||
|
||||
.action-button {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
display: block;
|
||||
padding: 0 $baseline/2;
|
||||
width: auto;
|
||||
@@ -1352,11 +1350,17 @@ div.wrapper-comp-editor.is-inactive ~ div.launch-latex-compiler{
|
||||
body.unit .xblock-type-container {
|
||||
@extend %wrap-xblock;
|
||||
margin: 0;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
|
||||
&:hover {
|
||||
@include transition(all $tmg-f2 linear 0s);
|
||||
border-color: $blue;
|
||||
box-shadow: 0 0 1px $shadow-d1;
|
||||
|
||||
.container-drag {
|
||||
background-color: $blue;
|
||||
border-color: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
.xblock-header {
|
||||
@@ -1369,7 +1373,31 @@ body.unit .xblock-type-container {
|
||||
}
|
||||
}
|
||||
|
||||
// UI: container xblock drag handle
|
||||
|
||||
// TODO: abstract out drag handles into generic control used on unit, container, outline pages.
|
||||
.container-drag {
|
||||
position: absolute;
|
||||
display: block;
|
||||
top: 0px;
|
||||
right: -16px;
|
||||
z-index: 10;
|
||||
width: 16px;
|
||||
height: 50px;
|
||||
border-radius: 0 3px 3px 0;
|
||||
border: 1px solid $lightBluishGrey2;
|
||||
background: url(../img/white-drag-handles.png) center no-repeat $lightBluishGrey2;
|
||||
cursor: move;
|
||||
@include transition(none);
|
||||
}
|
||||
|
||||
.xblock-render {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// UI: special case discussion xmodule styling
|
||||
|
||||
body.unit .component .xmodule_DiscussionModule {
|
||||
margin-top: ($baseline*1.5);
|
||||
}
|
||||
|
||||
91
cms/templates/container.html
Normal file
91
cms/templates/container.html
Normal file
@@ -0,0 +1,91 @@
|
||||
<%inherit file="base.html" />
|
||||
<%!
|
||||
import json
|
||||
|
||||
from contentstore.views.helpers import xblock_studio_url
|
||||
from django.utils.translation import ugettext as _
|
||||
%>
|
||||
<%block name="title">${_("Container")}</%block>
|
||||
<%block name="bodyclass">is-signedin course container view-container</%block>
|
||||
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
<%namespace name="units" file="widgets/units.html" />
|
||||
|
||||
|
||||
<%block name="jsextra">
|
||||
<%
|
||||
xblock_info = {
|
||||
'id': str(xblock_locator),
|
||||
'display-name': xblock.display_name,
|
||||
'category': xblock.category,
|
||||
};
|
||||
%>
|
||||
<script type='text/javascript'>
|
||||
require(["domReady!", "jquery", "js/models/xblock_info", "js/views/xblock",
|
||||
"js/models/module_info", "coffee/src/views/unit",
|
||||
"xmodule", "coffee/src/main", "xblock/cms.runtime.v1",
|
||||
"js/views/metadata", "js/collections/metadata"],
|
||||
function(doc, $, XBlockInfo, XBlockView) {
|
||||
var model,
|
||||
view;
|
||||
model = new XBlockInfo(${json.dumps(xblock_info) | n});
|
||||
view = new XBlockView({
|
||||
el: $('.wrapper-xblock.level-page').first(),
|
||||
model: model,
|
||||
view: 'container_preview'
|
||||
});
|
||||
view.render();
|
||||
});
|
||||
</script>
|
||||
|
||||
</%block>
|
||||
|
||||
<%block name="content">
|
||||
|
||||
|
||||
<div class="wrapper-mast wrapper">
|
||||
<header class="mast has-actions has-navigation">
|
||||
<h1 class="page-header">
|
||||
<small class="navigation navigation-parents">
|
||||
<%
|
||||
parent_url = xblock_studio_url(parent_xblock, context_course)
|
||||
%>
|
||||
% if parent_url:
|
||||
<a href="${parent_url}"
|
||||
class="navigation-link navigation-parent">${parent_xblock.display_name | h}</a>
|
||||
% endif
|
||||
<a href="#" class="navigation-link navigation-current">${xblock.display_name | h}</a>
|
||||
</small>
|
||||
</h1>
|
||||
|
||||
<nav class="nav-actions">
|
||||
<h3 class="sr">${_("Page Actions")}</h3>
|
||||
<ul>
|
||||
<li class="sr nav-item">
|
||||
${_("No Actions")}
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="wrapper-content wrapper">
|
||||
<div class="inner-wrapper">
|
||||
<section class="content-area">
|
||||
|
||||
<article class="content-primary window">
|
||||
<section class="wrapper-xblock level-page" data-locator="${xblock_locator}"/>
|
||||
</article>
|
||||
<aside class="content-supplementary" role="complimentary">
|
||||
<div class="bit">
|
||||
<h3 class="title-3">${_("What can I do on this page?")}</h3>
|
||||
<ul class="list-details">
|
||||
<li class="item-detail">${_("You can view course components that contain other components on this page. In the case of experiment blocks, this allows you to confirm that you have properly configured your experiment groups.")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</%block>
|
||||
@@ -8,19 +8,21 @@
|
||||
<%block name="bodyclass">is-signedin course view-static-pages</%block>
|
||||
|
||||
<%block name="jsextra">
|
||||
<script type='text/javascript'>
|
||||
require(["js/models/explicit_url", "coffee/src/views/tabs"], function(TabsModel, TabsEditView) {
|
||||
var model = new TabsModel({
|
||||
id: "${course_locator}",
|
||||
explicit_url: "${course_locator.url_reverse('tabs')}"
|
||||
});
|
||||
<script type='text/javascript'>
|
||||
require(["js/models/explicit_url", "coffee/src/views/tabs",
|
||||
"xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
|
||||
function (TabsModel, TabsEditView) {
|
||||
var model = new TabsModel({
|
||||
id: "${course_locator}",
|
||||
explicit_url: "${course_locator.url_reverse('tabs')}"
|
||||
});
|
||||
|
||||
new TabsEditView({
|
||||
el: $('.main-wrapper'),
|
||||
model: model,
|
||||
mast: $('.wrapper-mast')
|
||||
});
|
||||
});
|
||||
new TabsEditView({
|
||||
el: $('.main-wrapper'),
|
||||
model: model,
|
||||
mast: $('.wrapper-mast')
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</%block>
|
||||
|
||||
|
||||
4
cms/templates/js/mock/mock-collapsible-view.underscore
Normal file
4
cms/templates/js/mock/mock-collapsible-view.underscore
Normal file
@@ -0,0 +1,4 @@
|
||||
<div class="is-collapsible">
|
||||
<a href="#" class="expand-collapse collapse"><i class="ui-toggle-expansion">Expand/Collapse</i></a>
|
||||
<div class="content">Mock Content</div>
|
||||
</div>
|
||||
17
cms/templates/js/mock/mock-xblock.underscore
Normal file
17
cms/templates/js/mock/mock-xblock.underscore
Normal file
@@ -0,0 +1,17 @@
|
||||
<header class="xblock-header">
|
||||
<div class="header-details">
|
||||
<span>Mock XBlock</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
<li class="sr action-item">No Actions</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
<article class="xblock-render">
|
||||
<div class="xblock xblock-student_view xmodule_display xmodule_VerticalModule"
|
||||
data-runtime-class="PreviewRuntime" data-init="XBlockToXModuleShim" data-runtime-version="1"
|
||||
data-type="None">
|
||||
<p>Mock XBlock</p>
|
||||
</div>
|
||||
</article>
|
||||
24
cms/templates/studio_vertical_wrapper.html
Normal file
24
cms/templates/studio_vertical_wrapper.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
% if xblock.location != xblock_context['root_xblock'].location:
|
||||
<section class="wrapper-xblock level-nesting is-collapsible" data-locator="${locator}">
|
||||
% endif
|
||||
<header class="xblock-header">
|
||||
<div class="header-details">
|
||||
<a href="#" data-tooltip="${_('Expand or Collapse')}" class="action expand-collapse collapse">
|
||||
<i class="icon-caret-down ui-toggle-expansion"></i>
|
||||
<span class="sr">${_('Expand or Collapse')}</span>
|
||||
</a>
|
||||
<span>${xblock.display_name | h}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
<li class="sr action-item">${_('No Actions')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
<article class="xblock-render">
|
||||
${content}
|
||||
</article>
|
||||
% if xblock.location != xblock_context['root_xblock'].location:
|
||||
</section>
|
||||
% endif
|
||||
43
cms/templates/studio_xblock_wrapper.html
Normal file
43
cms/templates/studio_xblock_wrapper.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
% if xblock.location != xblock_context['root_xblock'].location:
|
||||
% if xblock.has_children:
|
||||
<section class="wrapper-xblock level-nesting" data-locator="${locator}">
|
||||
% else:
|
||||
<section class="wrapper-xblock level-element" data-locator="${locator}">
|
||||
% endif
|
||||
% endif
|
||||
<header class="xblock-header">
|
||||
<div class="header-details">
|
||||
${xblock.display_name | h}
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
% if not xblock_context['read_only']:
|
||||
<li class="action-item action-edit">
|
||||
<a href="#" class="edit-button action-button">
|
||||
<i class="icon-edit"></i>
|
||||
<span class="action-button-text">${_("Edit")}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" data-tooltip="${_("Duplicate")}" class="duplicate-button action-button">
|
||||
<i class="icon-copy"></i>
|
||||
<span class="sr">${_("Duplicate this component")}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="action-item action-delete">
|
||||
<a href="#" data-tooltip="${_("Delete")}" class="delete-button action-button">
|
||||
<i class="icon-trash"></i>
|
||||
<span class="sr">${_("Delete this component")}</span>
|
||||
</a>
|
||||
</li>
|
||||
% endif
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
<article class="xblock-render">
|
||||
${content}
|
||||
</article>
|
||||
% if xblock.location != xblock_context['root_xblock'].location:
|
||||
</section>
|
||||
% endif
|
||||
@@ -11,7 +11,8 @@ from xmodule.modulestore.django import loc_mapper
|
||||
|
||||
<%block name="jsextra">
|
||||
<script type='text/javascript'>
|
||||
require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit", "jquery.ui"],
|
||||
require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit", "jquery.ui",
|
||||
"xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
|
||||
function(doc, $, ModuleModel, UnitEditView, ui) {
|
||||
window.unit_location_analytics = '${unit_locator}';
|
||||
|
||||
@@ -20,6 +21,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
|
||||
|
||||
new UnitEditView({
|
||||
el: $('.main-wrapper'),
|
||||
view: 'unit',
|
||||
model: new ModuleModel({
|
||||
id: '${unit_locator}',
|
||||
state: '${unit_state}'
|
||||
@@ -53,7 +55,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
|
||||
<article class="unit-body window">
|
||||
<p class="unit-name-input"><label for="unit-display-name-input">${_("Display Name:")}</label><input type="text" value="${unit.display_name_with_default | h}" id="unit-display-name-input" class="unit-display-name-input" /></p>
|
||||
<ol class="components">
|
||||
% for locator in components:
|
||||
% for locator in locators:
|
||||
<li class="component" data-locator="${locator}"/>
|
||||
% endfor
|
||||
<li class="new-component-item adding">
|
||||
|
||||
25
cms/templates/unit_container_xblock_component.html
Normal file
25
cms/templates/unit_container_xblock_component.html
Normal file
@@ -0,0 +1,25 @@
|
||||
<%!
|
||||
from django.utils.translation import ugettext as _
|
||||
from contentstore.views.helpers import xblock_studio_url
|
||||
%>
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
|
||||
<section class="wrapper-xblock xblock-type-container level-element" data-locator="${locator}">
|
||||
<header class="xblock-header">
|
||||
<div class="header-details">
|
||||
${xblock.display_name}
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
<li class="action-item action-view">
|
||||
<a href="${xblock_studio_url(xblock, course)}" class="action-button">
|
||||
## Translators: this is a verb describing the action of viewing more details
|
||||
<span class="action-button-text">${_('View')}</span>
|
||||
<i class="icon-arrow-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
<span data-tooltip="${_("Drag to reorder")}" class="drag-handle"></span>
|
||||
</section>
|
||||
@@ -391,6 +391,7 @@ from django.utils.translation import ugettext as _
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
<span data-tooltip="Drag to reorder" class="container-drag drag-handle"></span>
|
||||
<article class="xblock-render">Shows Element - Example Randomize Block could be here.</article>
|
||||
</section>
|
||||
</li>
|
||||
|
||||
@@ -76,6 +76,7 @@ urlpatterns += patterns(
|
||||
url(r'(?ix)^course($|/){}$'.format(parsers.URL_RE_SOURCE), 'course_handler'),
|
||||
url(r'(?ix)^subsection($|/){}$'.format(parsers.URL_RE_SOURCE), 'subsection_handler'),
|
||||
url(r'(?ix)^unit($|/){}$'.format(parsers.URL_RE_SOURCE), 'unit_handler'),
|
||||
url(r'(?ix)^container($|/){}$'.format(parsers.URL_RE_SOURCE), 'container_handler'),
|
||||
url(r'(?ix)^checklists/{}(/)?(?P<checklist_index>\d+)?$'.format(parsers.URL_RE_SOURCE), 'checklists_handler'),
|
||||
url(r'(?ix)^orphan/{}$'.format(parsers.URL_RE_SOURCE), 'orphan_handler'),
|
||||
url(r'(?ix)^assets/{}(/)?(?P<asset_id>.+)?$'.format(parsers.URL_RE_SOURCE), 'assets_handler'),
|
||||
|
||||
74
common/test/acceptance/pages/studio/container.py
Normal file
74
common/test/acceptance/pages/studio/container.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Container page in Studio
|
||||
"""
|
||||
|
||||
from bok_choy.page_object import PageObject
|
||||
|
||||
from . import BASE_URL
|
||||
|
||||
|
||||
class ContainerPage(PageObject):
|
||||
"""
|
||||
Container page in Studio
|
||||
"""
|
||||
|
||||
def __init__(self, browser, unit_locator):
|
||||
super(ContainerPage, self).__init__(browser)
|
||||
self.unit_locator = unit_locator
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
"""URL to the container page for an xblock."""
|
||||
return "{}/container/{}".format(BASE_URL, self.unit_locator)
|
||||
|
||||
def is_browser_on_page(self):
|
||||
# Wait until all components have been loaded
|
||||
return (
|
||||
self.is_css_present('body.view-container') and
|
||||
len(self.q(css=XBlockWrapper.BODY_SELECTOR)) == len(self.q(css='{} .xblock'.format(XBlockWrapper.BODY_SELECTOR)))
|
||||
)
|
||||
|
||||
@property
|
||||
def xblocks(self):
|
||||
"""
|
||||
Return a list of xblocks loaded on the container page.
|
||||
"""
|
||||
return self.q(css=XBlockWrapper.BODY_SELECTOR).map(lambda el: XBlockWrapper(self.browser, el['data-locator'])).results
|
||||
|
||||
|
||||
class XBlockWrapper(PageObject):
|
||||
"""
|
||||
A PageObject representing a wrapper around an XBlock child shown on the Studio container page.
|
||||
"""
|
||||
url = None
|
||||
BODY_SELECTOR = '.wrapper-xblock'
|
||||
NAME_SELECTOR = '.header-details'
|
||||
|
||||
def __init__(self, browser, locator):
|
||||
super(XBlockWrapper, self).__init__(browser)
|
||||
self.locator = locator
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.is_css_present('{}[data-locator="{}"]'.format(self.BODY_SELECTOR, self.locator))
|
||||
|
||||
def _bounded_selector(self, selector):
|
||||
"""
|
||||
Return `selector`, but limited to this particular `CourseOutlineChild` context
|
||||
"""
|
||||
return '{}[data-locator="{}"] {}'.format(
|
||||
self.BODY_SELECTOR,
|
||||
self.locator,
|
||||
selector
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
titles = self.css_text(self._bounded_selector(self.NAME_SELECTOR))
|
||||
if titles:
|
||||
return titles[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def preview_selector(self):
|
||||
return self._bounded_selector('.xblock-student_view')
|
||||
@@ -8,7 +8,6 @@ from bok_choy.promise import EmptyPromise, fulfill
|
||||
from .course_page import CoursePage
|
||||
from .unit import UnitPage
|
||||
|
||||
|
||||
class CourseOutlineContainer(object):
|
||||
"""
|
||||
A mixin to a CourseOutline page object that adds the ability to load
|
||||
@@ -18,11 +17,13 @@ class CourseOutlineContainer(object):
|
||||
"""
|
||||
CHILD_CLASS = None
|
||||
|
||||
def child(self, title):
|
||||
return self.CHILD_CLASS(
|
||||
def child(self, title, child_class=None):
|
||||
if not child_class:
|
||||
child_class = self.CHILD_CLASS
|
||||
return child_class(
|
||||
self.browser,
|
||||
self.q(css=self.CHILD_CLASS.BODY_SELECTOR).filter(
|
||||
SubQuery(css=self.CHILD_CLASS.NAME_SELECTOR).filter(text=title)
|
||||
self.q(css=child_class.BODY_SELECTOR).filter(
|
||||
SubQuery(css=child_class.NAME_SELECTOR).filter(text=title)
|
||||
)[0]['data-locator']
|
||||
)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from bok_choy.query import SubQuery
|
||||
from bok_choy.promise import EmptyPromise, fulfill
|
||||
|
||||
from . import BASE_URL
|
||||
from .container import ContainerPage
|
||||
|
||||
|
||||
class UnitPage(PageObject):
|
||||
@@ -25,9 +26,11 @@ class UnitPage(PageObject):
|
||||
|
||||
def is_browser_on_page(self):
|
||||
# Wait until all components have been loaded
|
||||
number_of_leaf_xblocks = len(self.q(css='{} .xblock-student_view'.format(Component.BODY_SELECTOR)))
|
||||
number_of_container_xblocks = len(self.q(css='{} .wrapper-xblock'.format(Component.BODY_SELECTOR)))
|
||||
return (
|
||||
self.is_css_present('body.view-unit') and
|
||||
len(self.q(css=Component.BODY_SELECTOR)) == len(self.q(css='{} .xblock-student_view'.format(Component.BODY_SELECTOR)))
|
||||
len(self.q(css=Component.BODY_SELECTOR)) == number_of_leaf_xblocks + number_of_container_xblocks
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -105,3 +108,10 @@ class Component(PageObject):
|
||||
@property
|
||||
def editor_selector(self):
|
||||
return self._bounded_selector('.xblock-studio_view')
|
||||
|
||||
def go_to_container(self):
|
||||
"""
|
||||
Open the container page linked to by this component, and return
|
||||
an initialized :class:`.ContainerPage` for that xblock.
|
||||
"""
|
||||
return ContainerPage(self.browser, self.locator).visit()
|
||||
|
||||
@@ -153,9 +153,11 @@ class XBlockAcidBase(WebAppTest):
|
||||
"""
|
||||
|
||||
self.outline.visit()
|
||||
unit = self.outline.section('Test Section').subsection('Test Subsection').toggle_expand().unit('Test Unit').go_to()
|
||||
subsection = self.outline.section('Test Section').subsection('Test Subsection')
|
||||
unit = subsection.toggle_expand().unit('Test Unit').go_to()
|
||||
container = unit.components[0].go_to_container()
|
||||
|
||||
acid_block = AcidView(self.browser, unit.components[0].preview_selector)
|
||||
acid_block = AcidView(self.browser, container.xblocks[0].preview_selector)
|
||||
self.assertTrue(acid_block.init_fn_passed)
|
||||
self.assertTrue(acid_block.child_tests_passed)
|
||||
self.assertTrue(acid_block.resource_url_passed)
|
||||
@@ -164,13 +166,16 @@ class XBlockAcidBase(WebAppTest):
|
||||
self.assertTrue(acid_block.scope_passed('preferences'))
|
||||
self.assertTrue(acid_block.scope_passed('user_info'))
|
||||
|
||||
# This will fail until we support editing on the container page
|
||||
@expectedFailure
|
||||
def test_acid_block_editor(self):
|
||||
"""
|
||||
Verify that all expected acid block tests pass in studio preview
|
||||
"""
|
||||
|
||||
self.outline.visit()
|
||||
unit = self.outline.section('Test Section').subsection('Test Subsection').toggle_expand().unit('Test Unit').go_to()
|
||||
subsection = self.outline.section('Test Section').subsection('Test Subsection')
|
||||
unit = subsection.toggle_expand().unit('Test Unit').go_to()
|
||||
|
||||
unit.edit_draft()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user