Commit 9cee08ea by Andy Armstrong

Merge pull request #2539 from edx/andya/nested-xblocks

Add new container page that can display nested xblocks
parents 7884e499 fcc0231d
...@@ -5,12 +5,14 @@ These are notable changes in edx-platform. This is a rolling list of changes, ...@@ -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 in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected. 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. Blades: Allow multiple transcripts with video. BLD-642.
CMS: Add feature to allow exporting a course to a git repository by CMS: Add feature to allow exporting a course to a git repository by
specifying the giturl in the course settings. 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. Blades: Persist student progress in video. BLD-385.
......
...@@ -484,7 +484,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -484,7 +484,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
""" """
Tests the ajax callback to render an XModule 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. # These are the data-ids of the xblocks contained in the vertical.
# Ultimately, these must be converted to new locators. # Ultimately, these must be converted to new locators.
self.assertContains(resp, 'i4x://edX/toy/video/sample_video') self.assertContains(resp, 'i4x://edX/toy/video/sample_video')
...@@ -492,7 +492,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -492,7 +492,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
self.assertContains(resp, 'i4x://edX/toy/video/video_with_end_time') self.assertContains(resp, 'i4x://edX/toy/video/video_with_end_time')
self.assertContains(resp, 'i4x://edX/toy/poll_question/T1_changemind_poll_foo_2') 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. """ """ Preview test case. """
direct_store = modulestore('direct') direct_store = modulestore('direct')
_, course_items = import_from_xml(direct_store, 'common/test/data/', ['toy']) _, course_items = import_from_xml(direct_store, 'common/test/data/', ['toy'])
...@@ -501,7 +501,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -501,7 +501,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
locator = loc_mapper().translate_location( locator = loc_mapper().translate_location(
course_items[0].location.course_id, location, True, True 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) self.assertEqual(resp.status_code, 200)
# TODO: uncomment when preview no longer has locations being returned. # TODO: uncomment when preview no longer has locations being returned.
# _test_no_locations(self, resp) # _test_no_locations(self, resp)
......
...@@ -57,12 +57,6 @@ class AjaxEnabledTestClient(Client): ...@@ -57,12 +57,6 @@ class AjaxEnabledTestClient(Client):
""" """
return self.get(path, data or {}, follow, HTTP_ACCEPT="application/json", **extra) 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) @override_settings(MODULESTORE=TEST_MODULESTORE)
......
...@@ -6,7 +6,7 @@ from collections import defaultdict ...@@ -6,7 +6,7 @@ from collections import defaultdict
from django.http import HttpResponseBadRequest, Http404 from django.http import HttpResponseBadRequest, Http404
from django.contrib.auth.decorators import login_required 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.core.exceptions import PermissionDenied
from django.conf import settings from django.conf import settings
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
...@@ -28,6 +28,7 @@ from xmodule.x_module import prefer_xmodules ...@@ -28,6 +28,7 @@ from xmodule.x_module import prefer_xmodules
from lms.lib.xblock.runtime import unquote_slashes from lms.lib.xblock.runtime import unquote_slashes
from contentstore.utils import get_lms_link_for_item, compute_unit_state, UnitState 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 from models.settings.course_grading import CourseGradingModel
...@@ -37,6 +38,7 @@ __all__ = ['OPEN_ENDED_COMPONENT_TYPES', ...@@ -37,6 +38,7 @@ __all__ = ['OPEN_ENDED_COMPONENT_TYPES',
'ADVANCED_COMPONENT_POLICY_KEY', 'ADVANCED_COMPONENT_POLICY_KEY',
'subsection_handler', 'subsection_handler',
'unit_handler', 'unit_handler',
'container_handler',
'component_handler' 'component_handler'
] ]
...@@ -65,7 +67,7 @@ ADVANCED_COMPONENT_CATEGORY = 'advanced' ...@@ -65,7 +67,7 @@ ADVANCED_COMPONENT_CATEGORY = 'advanced'
ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules' ADVANCED_COMPONENT_POLICY_KEY = 'advanced_modules'
@require_http_methods(["GET"]) @require_GET
@login_required @login_required
def subsection_handler(request, tag=None, package_id=None, branch=None, version_guid=None, block=None): 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_ ...@@ -89,17 +91,7 @@ def subsection_handler(request, tag=None, package_id=None, branch=None, version_
if item.location.category != 'sequential': if item.location.category != 'sequential':
return HttpResponseBadRequest() return HttpResponseBadRequest()
parent_locs = modulestore().get_parent_locations(old_location, None) parent = get_parent_xblock(item)
# 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])
# remove all metadata from the generic dictionary that is presented in a # remove all metadata from the generic dictionary that is presented in a
# more normalized UI. We only want to display the XBlocks fields, not # more normalized UI. We only want to display the XBlocks fields, not
...@@ -154,7 +146,7 @@ def _load_mixed_class(category): ...@@ -154,7 +146,7 @@ def _load_mixed_class(category):
return mixologist.mix(component_class) return mixologist.mix(component_class)
@require_http_methods(["GET"]) @require_GET
@login_required @login_required
def unit_handler(request, tag=None, package_id=None, branch=None, version_guid=None, block=None): 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 ...@@ -236,24 +228,19 @@ def unit_handler(request, tag=None, package_id=None, branch=None, version_guid=N
course_advanced_keys course_advanced_keys
) )
components = [ xblocks = item.get_children()
locators = [
loc_mapper().translate_location( loc_mapper().translate_location(
course.location.course_id, component.location, False, True course.location.course_id, xblock.location, False, True
) )
for component for xblock in xblocks
in item.get_children()
] ]
# TODO (cpennington): If we share units between courses, # TODO (cpennington): If we share units between courses,
# this will need to change to check permissions correctly so as # this will need to change to check permissions correctly so as
# to pick the correct parent subsection # to pick the correct parent subsection
containing_subsection = get_parent_xblock(item)
containing_subsection_locs = modulestore().get_parent_locations(old_location, None) containing_section = get_parent_xblock(containing_subsection)
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])
# cdodge hack. We're having trouble previewing drafts via jump_to redirect # cdodge hack. We're having trouble previewing drafts via jump_to redirect
# so let's generate the link url here # 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 ...@@ -285,7 +272,7 @@ def unit_handler(request, tag=None, package_id=None, branch=None, version_guid=N
'context_course': course, 'context_course': course,
'unit': item, 'unit': item,
'unit_locator': locator, 'unit_locator': locator,
'components': components, 'locators': locators,
'component_templates': component_templates, 'component_templates': component_templates,
'draft_preview_link': preview_lms_link, 'draft_preview_link': preview_lms_link,
'published_preview_link': 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 ...@@ -306,6 +293,35 @@ def unit_handler(request, tag=None, package_id=None, branch=None, version_guid=N
return HttpResponseBadRequest("Only supports html requests") 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 @login_required
def _get_item_in_course(request, locator): def _get_item_in_course(request, locator):
""" """
......
import logging
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from edxmako.shortcuts import render_to_string, render_to_response from edxmako.shortcuts import render_to_string, render_to_response
from xmodule.modulestore.django import loc_mapper, modulestore
__all__ = ['edge', 'event', 'landing'] __all__ = ['edge', 'event', 'landing']
...@@ -35,3 +38,64 @@ def _xmodule_recurse(item, action): ...@@ -35,3 +38,64 @@ def _xmodule_recurse(item, action):
_xmodule_recurse(child, action) _xmodule_recurse(child, action)
action(item) 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 ...@@ -12,7 +12,7 @@ from xmodule_modifiers import wrap_xblock
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.contrib.auth.decorators import login_required 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.utils.translation import ugettext as _
from django.views.decorators.http import require_http_methods 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 ...@@ -164,7 +164,6 @@ def xblock_handler(request, tag=None, package_id=None, branch=None, version_guid
content_type="text/plain" content_type="text/plain"
) )
# pylint: disable=unused-argument # pylint: disable=unused-argument
@require_http_methods(("GET")) @require_http_methods(("GET"))
@login_required @login_required
...@@ -185,7 +184,7 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v ...@@ -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') 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) store = get_modulestore(old_location)
component = store.get_item(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 ...@@ -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)})) fragment = Fragment(render_to_string('html_error.html', {'message': str(exc)}))
store.save_xmodule(component) store.save_xmodule(component)
elif view_name == 'student_view' and component.has_children:
elif view_name == 'student_view': # For non-leaf xblocks on the unit page, show the special rendering
fragment = get_preview_fragment(request, component) # which links to the new container page.
fragment.content = render_to_string('component.html', { course_location = loc_mapper().translate_locator_to_location(locator, True)
'preview': fragment.content, course = store.get_item(course_location)
'label': component.display_name or component.scope_ids.block_type, html = render_to_string('unit_container_xblock_component.html', {
'course': course,
# Native XBlocks are responsible for persisting their own data, 'xblock': component,
# so they are also responsible for providing save/cancel buttons. 'locator': locator
'show_save_cancel': isinstance(component, xmodule.x_module.XModuleDescriptor), })
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: else:
raise Http404 raise Http404
......
...@@ -10,7 +10,7 @@ from django.http import Http404, HttpResponseBadRequest ...@@ -10,7 +10,7 @@ from django.http import Http404, HttpResponseBadRequest
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from edxmako.shortcuts import render_to_string 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.error_module import ErrorDescriptor
from xmodule.exceptions import NotFoundError, ProcessingError from xmodule.exceptions import NotFoundError, ProcessingError
from xmodule.modulestore.django import modulestore, loc_mapper, ModuleI18nService from xmodule.modulestore.django import modulestore, loc_mapper, ModuleI18nService
...@@ -108,6 +108,17 @@ def _preview_module_system(request, descriptor): ...@@ -108,6 +108,17 @@ def _preview_module_system(request, descriptor):
course_id = course_location.course_id course_id = course_location.course_id
else: else:
course_id = get_course_for_item(descriptor.location).location.course_id 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( return PreviewModuleSystem(
static_url=settings.STATIC_URL, static_url=settings.STATIC_URL,
...@@ -125,14 +136,7 @@ def _preview_module_system(request, descriptor): ...@@ -125,14 +136,7 @@ def _preview_module_system(request, descriptor):
anonymous_student_id='student', anonymous_student_id='student',
# Set up functions to modify the fragment produced by student_view # Set up functions to modify the fragment produced by student_view
wrappers=( wrappers=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),
),
error_descriptor_class=ErrorDescriptor, error_descriptor_class=ErrorDescriptor,
# get_user_role accepts a location or a CourseLocator. # get_user_role accepts a location or a CourseLocator.
# If descriptor.location is a CourseLocator, course_id is unused. # If descriptor.location is a CourseLocator, course_id is unused.
...@@ -159,14 +163,38 @@ def _load_preview_module(request, descriptor): ...@@ -159,14 +163,38 @@ def _load_preview_module(request, descriptor):
return 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, Returns the HTML returned by the XModule's student_view,
specified by the descriptor and idx. specified by the descriptor and idx.
""" """
module = _load_preview_module(request, descriptor) module = _load_preview_module(request, descriptor)
try: try:
fragment = module.render("student_view") fragment = module.render("student_view", context)
except Exception as exc: # pylint: disable=W0703 except Exception as exc: # pylint: disable=W0703
log.warning("Unable to render student_view for %r", module, exc_info=True) log.warning("Unable to render student_view for %r", module, exc_info=True)
fragment = Fragment(render_to_string('html_error.html', {'message': str(exc)})) fragment = Fragment(render_to_string('html_error.html', {'message': str(exc)}))
......
"""
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)
"""
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): ...@@ -70,6 +70,28 @@ class ItemTest(CourseTestCase):
class GetItem(ItemTest): class GetItem(ItemTest):
"""Tests for '/xblock' GET url.""" """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): def test_get_vertical(self):
# Add a vertical # Add a vertical
resp = self.create_xblock(category='vertical') resp = self.create_xblock(category='vertical')
...@@ -80,6 +102,36 @@ class GetItem(ItemTest): ...@@ -80,6 +102,36 @@ class GetItem(ItemTest):
resp = self.client.get('/xblock/' + resp_content['locator']) resp = self.client.get('/xblock/' + resp_content['locator'])
self.assertEqual(resp.status_code, 200) 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): class DeleteItem(ItemTest):
"""Tests for '/xblock' DELETE url.""" """Tests for '/xblock' DELETE url."""
...@@ -565,11 +617,12 @@ class TestEditItem(ItemTest): ...@@ -565,11 +617,12 @@ class TestEditItem(ItemTest):
self.assertNotEqual(draft.data, published.data) self.assertNotEqual(draft.data, published.data)
# Get problem by 'xblock_handler' # 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) self.assertEqual(resp.status_code, 200)
# Activate the editing view # 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) self.assertEqual(resp.status_code, 200)
# Both published and draft content should still be different # Both published and draft content should still be different
...@@ -647,8 +700,8 @@ class TestNativeXBlock(ItemTest): ...@@ -647,8 +700,8 @@ class TestNativeXBlock(ItemTest):
native_loc = json.loads(resp.content)['locator'] native_loc = json.loads(resp.content)['locator']
# Render the XBlock # Render the XBlock
resp_content = json.loads(resp.content) view_url = '/xblock/{locator}/student_view'.format(locator=native_loc)
resp = self.client.get('/xblock/' + native_loc + '/student_view', HTTP_ACCEPT='application/x-fragment+json') resp = self.client.get(view_url, HTTP_ACCEPT='application/json')
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
# Check that the save and cancel buttons are hidden for native XBlocks, # Check that the save and cancel buttons are hidden for native XBlocks,
......
...@@ -45,7 +45,7 @@ class GetPreviewHtmlTestCase(TestCase): ...@@ -45,7 +45,7 @@ class GetPreviewHtmlTestCase(TestCase):
# Must call get_preview_fragment directly, as going through xblock RESTful API will attempt # Must call get_preview_fragment directly, as going through xblock RESTful API will attempt
# to use item.location as a Location. # 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. # Verify student view html is returned, and there are no old locations in it.
self.assertRegexpMatches( self.assertRegexpMatches(
html, html,
......
...@@ -216,6 +216,7 @@ define([ ...@@ -216,6 +216,7 @@ define([
"js/spec/views/paging_spec", "js/spec/views/paging_spec",
"js/spec/views/unit_spec" "js/spec/views/unit_spec"
"js/spec/views/xblock_spec"
# these tests are run separate in the cms-squire suite, due to process # these tests are run separate in the cms-squire suite, due to process
# isolation issues with Squire.js # isolation issues with Squire.js
......
...@@ -50,7 +50,7 @@ define ["jquery", "coffee/src/views/module_edit", "js/models/module_info", "xmod ...@@ -50,7 +50,7 @@ define ["jquery", "coffee/src/views/module_edit", "js/models/module_info", "xmod
) )
it "renders the module editor", -> it "renders the module editor", ->
expect(@moduleEdit.render).toHaveBeenCalled() expect(ModuleEdit.prototype.render).toHaveBeenCalled()
describe "render", -> describe "render", ->
beforeEach -> beforeEach ->
...@@ -80,7 +80,7 @@ define ["jquery", "coffee/src/views/module_edit", "js/models/module_info", "xmod ...@@ -80,7 +80,7 @@ define ["jquery", "coffee/src/views/module_edit", "js/models/module_info", "xmod
url: "/xblock/#{@moduleEdit.model.id}/student_view" url: "/xblock/#{@moduleEdit.model.id}/student_view"
type: "GET" type: "GET"
headers: headers:
Accept: 'application/x-fragment+json' Accept: 'application/json'
success: jasmine.any(Function) success: jasmine.any(Function)
) )
...@@ -88,7 +88,7 @@ define ["jquery", "coffee/src/views/module_edit", "js/models/module_info", "xmod ...@@ -88,7 +88,7 @@ define ["jquery", "coffee/src/views/module_edit", "js/models/module_info", "xmod
url: "/xblock/#{@moduleEdit.model.id}/studio_view" url: "/xblock/#{@moduleEdit.model.id}/studio_view"
type: "GET" type: "GET"
headers: headers:
Accept: 'application/x-fragment+json' Accept: 'application/json'
success: jasmine.any(Function) success: jasmine.any(Function)
) )
expect(@moduleEdit.loadDisplay).toHaveBeenCalled() expect(@moduleEdit.loadDisplay).toHaveBeenCalled()
...@@ -100,7 +100,7 @@ define ["jquery", "coffee/src/views/module_edit", "js/models/module_info", "xmod ...@@ -100,7 +100,7 @@ define ["jquery", "coffee/src/views/module_edit", "js/models/module_info", "xmod
url: "/xblock/#{@moduleEdit.model.id}/studio_view" url: "/xblock/#{@moduleEdit.model.id}/studio_view"
type: "GET" type: "GET"
headers: headers:
Accept: 'application/x-fragment+json' Accept: 'application/json'
success: jasmine.any(Function) success: jasmine.any(Function)
) )
expect(@moduleEdit.loadEdit).not.toHaveBeenCalled() expect(@moduleEdit.loadEdit).not.toHaveBeenCalled()
...@@ -123,7 +123,7 @@ define ["jquery", "coffee/src/views/module_edit", "js/models/module_info", "xmod ...@@ -123,7 +123,7 @@ define ["jquery", "coffee/src/views/module_edit", "js/models/module_info", "xmod
url: "/xblock/#{@moduleEdit.model.id}/studio_view" url: "/xblock/#{@moduleEdit.model.id}/studio_view"
type: "GET" type: "GET"
headers: headers:
Accept: 'application/x-fragment+json' Accept: 'application/json'
success: jasmine.any(Function) success: jasmine.any(Function)
) )
expect(@moduleEdit.loadEdit).toHaveBeenCalled() expect(@moduleEdit.loadEdit).toHaveBeenCalled()
......
define ["backbone", "jquery", "underscore", "gettext", "xblock/runtime.v1", define ["jquery", "underscore", "gettext", "xblock/runtime.v1",
"js/views/feedback_notification", "js/views/metadata", "js/collections/metadata" "js/views/xblock", "js/views/feedback_notification", "js/views/metadata", "js/collections/metadata"
"js/utils/modal", "jquery.inputnumber", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"], "js/utils/modal", "jquery.inputnumber"],
(Backbone, $, _, gettext, XBlock, NotificationView, MetadataView, MetadataCollection, ModalUtils) -> ($, _, gettext, XBlock, XBlockView, NotificationView, MetadataView, MetadataCollection, ModalUtils) ->
class ModuleEdit extends Backbone.View class ModuleEdit extends XBlockView
tagName: 'li' tagName: 'li'
className: 'component' className: 'component'
editorMode: 'editor-mode' editorMode: 'editor-mode'
...@@ -79,31 +79,9 @@ define ["backbone", "jquery", "underscore", "gettext", "xblock/runtime.v1", ...@@ -79,31 +79,9 @@ define ["backbone", "jquery", "underscore", "gettext", "xblock/runtime.v1",
url: "#{decodeURIComponent(@model.url())}/#{viewName}" url: "#{decodeURIComponent(@model.url())}/#{viewName}"
type: 'GET' type: 'GET'
headers: headers:
Accept: 'application/x-fragment+json' Accept: 'application/json'
success: (data) => success: (fragment) =>
$(target).html(data.html) @renderXBlockFragment(fragment, target, viewName)
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)
callback() callback()
) )
......
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;
});
\ No newline at end of file
define( define(["jquery", "underscore", "js/views/baseview", "js/utils/handle_iframe_binding", "sinon"],
[ function ($, _, BaseView, IframeBinding, sinon) {
"jquery", "underscore",
"js/views/baseview",
"js/utils/handle_iframe_binding",
"sinon"
],
function ($, _, BaseView, IframeBinding, sinon) {
describe("BaseView check", function () {
var baseView;
var iframeBinding_spy;
beforeEach(function () {
iframeBinding_spy = sinon.spy(IframeBinding, "iframeBinding");
baseView = BaseView.prototype;
spyOn(baseView, 'initialize');
spyOn(baseView, 'beforeRender');
spyOn(baseView, 'render');
spyOn(baseView, 'afterRender').andCallThrough();
});
afterEach(function () { describe("BaseView", function() {
iframeBinding_spy.restore(); var baseViewPrototype;
});
it('calls before and after render functions when render of baseview is called', function () { describe("BaseView rendering", function () {
var baseview_temp = new BaseView() var iframeBinding_spy;
baseview_temp.render();
expect(baseView.initialize).toHaveBeenCalled(); beforeEach(function () {
expect(baseView.beforeRender).toHaveBeenCalled(); baseViewPrototype = BaseView.prototype;
expect(baseView.render).toHaveBeenCalled(); iframeBinding_spy = sinon.spy(IframeBinding, "iframeBinding");
expect(baseView.afterRender).toHaveBeenCalled();
}); spyOn(baseViewPrototype, 'initialize');
spyOn(baseViewPrototype, 'beforeRender');
spyOn(baseViewPrototype, 'render').andCallThrough();
spyOn(baseViewPrototype, 'afterRender').andCallThrough();
});
afterEach(function () {
iframeBinding_spy.restore();
});
it('calls before and after render functions when render of baseview is called', function () {
var baseView = new BaseView();
baseView.render();
expect(baseViewPrototype.initialize).toHaveBeenCalled();
expect(baseViewPrototype.beforeRender).toHaveBeenCalled();
expect(baseViewPrototype.render).toHaveBeenCalled();
expect(baseViewPrototype.afterRender).toHaveBeenCalled();
});
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('calls iframeBinding function when afterRender of baseview is called', function () { it('hides a collapsible node when clicking on the toggle link', function () {
var baseview_temp = new BaseView() view = new MockCollapsibleViewClass();
baseview_temp.render(); view.render();
expect(baseView.afterRender).toHaveBeenCalled(); view.$('.ui-toggle-expansion').click();
expect(iframeBinding_spy.called).toEqual(true); expect(view.$('.expand-collapse')).toHaveClass('expand');
expect(view.$('.expand-collapse')).not.toHaveClass('collapse');
expect(view.$('.is-collapsible')).toHaveClass('collapsed');
});
//check calls count of iframeBinding function it('expands a collapsible node when clicking twice on the toggle link', function () {
expect(iframeBinding_spy.callCount).toBe(1); view = new MockCollapsibleViewClass();
IframeBinding.iframeBinding(); view.render();
expect(iframeBinding_spy.callCount).toBe(2); 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');
});
});
}); });
}); });
});
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);
});
});
});
});
define( define(["jquery", "underscore", "backbone", "js/utils/handle_iframe_binding"],
[
'jquery',
'underscore',
'backbone',
"js/utils/handle_iframe_binding"
],
function ($, _, Backbone, IframeUtils) { 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 This view is extended from backbone to provide useful functionality for all Studio views.
function 'iframeBinding' which modifies iframe src urls on a page so that they are rendered as part of the DOM. This functionality includes:
Other common functions which need to be run before/after can also be added here. - 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({ Note: the default 'afterRender' function calls a utility function 'iframeBinding' which modifies
//override the constructor function iframe src urls on a page so that they are rendered as part of the DOM.
constructor: function(options) { */
_.bindAll(this, 'beforeRender', 'render', 'afterRender');
var _this = this; var BaseView = Backbone.View.extend({
this.render = _.wrap(this.render, function (render) { events: {
_this.beforeRender(); "click .ui-toggle-expansion": "toggleExpandCollapse"
render(); },
_this.afterRender();
return _this; //override the constructor function
}); constructor: function(options) {
_.bindAll(this, 'beforeRender', 'render', 'afterRender');
//call Backbone's own constructor var _this = this;
Backbone.View.prototype.constructor.apply(this, arguments); this.render = _.wrap(this.render, function (render) {
}, _this.beforeRender();
render();
beforeRender: function () { _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);
}
});
return BaseView; beforeRender: function() {
}); },
\ No newline at end of file
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;
});
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 @@ ...@@ -214,7 +214,6 @@
display: inline-block; display: inline-block;
.action-button { .action-button {
@include transition(all $tmg-f2 ease-in-out 0s);
border-radius: 3px; border-radius: 3px;
padding: ($baseline/4) ($baseline/2); padding: ($baseline/4) ($baseline/2);
height: ($baseline*1.5); height: ($baseline*1.5);
......
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
} }
[class^="icon-"] { [class^="icon-"] {
font-style: normal;
} }
.icon-inline { .icon-inline {
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
// extends - UI archetypes - xblock rendering // extends - UI archetypes - xblock rendering
%wrap-xblock { %wrap-xblock {
margin: ($baseline/2); margin: $baseline;
border: 1px solid $gray-l4; border: 1px solid $gray-l4;
border-radius: ($baseline/5); border-radius: ($baseline/5);
background: $white; background: $white;
...@@ -57,6 +57,10 @@ ...@@ -57,6 +57,10 @@
// UI: xblock is collapsible // UI: xblock is collapsible
.wrapper-xblock.is-collapsible { .wrapper-xblock.is-collapsible {
[class^="icon-"] {
font-style: normal;
}
.expand-collapse { .expand-collapse {
@extend %expand-collapse; @extend %expand-collapse;
margin: 0 ($baseline/4); margin: 0 ($baseline/4);
...@@ -74,4 +78,3 @@ ...@@ -74,4 +78,3 @@
} }
} }
} }
...@@ -4,6 +4,8 @@ ...@@ -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. // 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 // UI: container page view
body.view-container { body.view-container {
...@@ -64,11 +66,21 @@ body.view-container .content-primary{ ...@@ -64,11 +66,21 @@ body.view-container .content-primary{
border-bottom: none; border-bottom: none;
background: 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 // CASE: element level xblock rendering
&.level-element { &.level-element {
margin: 0 ($baseline*2) $baseline ($baseline*2);
box-shadow: none; box-shadow: none;
&:hover { &:hover {
...@@ -77,6 +89,7 @@ body.view-container .content-primary{ ...@@ -77,6 +89,7 @@ body.view-container .content-primary{
} }
.xblock-header { .xblock-header {
display: flex;
margin-bottom: 0; margin-bottom: 0;
border-bottom: 1px solid $gray-l4; border-bottom: 1px solid $gray-l4;
background-color: $gray-l6; background-color: $gray-l6;
...@@ -103,3 +116,21 @@ body.view-container .content-primary{ ...@@ -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 { ...@@ -950,7 +950,6 @@ body.course.unit,.view-unit {
body.unit { body.unit {
.component { .component {
padding-top: 30px;
.wrapper-component-action-header { .wrapper-component-action-header {
...@@ -1003,7 +1002,6 @@ body.unit { ...@@ -1003,7 +1002,6 @@ body.unit {
margin: ($baseline/4) 0 ($baseline/4) ($baseline/4); margin: ($baseline/4) 0 ($baseline/4) ($baseline/4);
.action-button { .action-button {
@include transition(all $tmg-f2 ease-in-out 0s);
display: block; display: block;
padding: 0 $baseline/2; padding: 0 $baseline/2;
width: auto; width: auto;
...@@ -1352,11 +1350,17 @@ div.wrapper-comp-editor.is-inactive ~ div.launch-latex-compiler{ ...@@ -1352,11 +1350,17 @@ div.wrapper-comp-editor.is-inactive ~ div.launch-latex-compiler{
body.unit .xblock-type-container { body.unit .xblock-type-container {
@extend %wrap-xblock; @extend %wrap-xblock;
margin: 0; margin: 0;
border: none;
box-shadow: none;
&:hover { &:hover {
@include transition(all $tmg-f2 linear 0s); @include transition(all $tmg-f2 linear 0s);
border-color: $blue; border-color: $blue;
box-shadow: 0 0 1px $shadow-d1;
.container-drag {
background-color: $blue;
border-color: $blue;
}
} }
.xblock-header { .xblock-header {
...@@ -1369,7 +1373,31 @@ body.unit .xblock-type-container { ...@@ -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 { .xblock-render {
display: none; display: none;
} }
} }
// UI: special case discussion xmodule styling
body.unit .component .xmodule_DiscussionModule {
margin-top: ($baseline*1.5);
}
<%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 @@ ...@@ -8,19 +8,21 @@
<%block name="bodyclass">is-signedin course view-static-pages</%block> <%block name="bodyclass">is-signedin course view-static-pages</%block>
<%block name="jsextra"> <%block name="jsextra">
<script type='text/javascript'> <script type='text/javascript'>
require(["js/models/explicit_url", "coffee/src/views/tabs"], function(TabsModel, TabsEditView) { require(["js/models/explicit_url", "coffee/src/views/tabs",
var model = new TabsModel({ "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
id: "${course_locator}", function (TabsModel, TabsEditView) {
explicit_url: "${course_locator.url_reverse('tabs')}" var model = new TabsModel({
}); id: "${course_locator}",
explicit_url: "${course_locator.url_reverse('tabs')}"
});
new TabsEditView({ new TabsEditView({
el: $('.main-wrapper'), el: $('.main-wrapper'),
model: model, model: model,
mast: $('.wrapper-mast') mast: $('.wrapper-mast')
}); });
}); });
</script> </script>
</%block> </%block>
......
<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>
<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>
<%! 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
<%! 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 ...@@ -11,7 +11,8 @@ from xmodule.modulestore.django import loc_mapper
<%block name="jsextra"> <%block name="jsextra">
<script type='text/javascript'> <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) { function(doc, $, ModuleModel, UnitEditView, ui) {
window.unit_location_analytics = '${unit_locator}'; window.unit_location_analytics = '${unit_locator}';
...@@ -20,6 +21,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit" ...@@ -20,6 +21,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
new UnitEditView({ new UnitEditView({
el: $('.main-wrapper'), el: $('.main-wrapper'),
view: 'unit',
model: new ModuleModel({ model: new ModuleModel({
id: '${unit_locator}', id: '${unit_locator}',
state: '${unit_state}' state: '${unit_state}'
...@@ -53,7 +55,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit" ...@@ -53,7 +55,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
<article class="unit-body window"> <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> <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"> <ol class="components">
% for locator in components: % for locator in locators:
<li class="component" data-locator="${locator}"/> <li class="component" data-locator="${locator}"/>
% endfor % endfor
<li class="new-component-item adding"> <li class="new-component-item adding">
......
<%!
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 _ ...@@ -391,6 +391,7 @@ from django.utils.translation import ugettext as _
</ul> </ul>
</div> </div>
</header> </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> <article class="xblock-render">Shows Element - Example Randomize Block could be here.</article>
</section> </section>
</li> </li>
......
...@@ -76,6 +76,7 @@ urlpatterns += patterns( ...@@ -76,6 +76,7 @@ urlpatterns += patterns(
url(r'(?ix)^course($|/){}$'.format(parsers.URL_RE_SOURCE), 'course_handler'), 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)^subsection($|/){}$'.format(parsers.URL_RE_SOURCE), 'subsection_handler'),
url(r'(?ix)^unit($|/){}$'.format(parsers.URL_RE_SOURCE), 'unit_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)^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)^orphan/{}$'.format(parsers.URL_RE_SOURCE), 'orphan_handler'),
url(r'(?ix)^assets/{}(/)?(?P<asset_id>.+)?$'.format(parsers.URL_RE_SOURCE), 'assets_handler'), url(r'(?ix)^assets/{}(/)?(?P<asset_id>.+)?$'.format(parsers.URL_RE_SOURCE), 'assets_handler'),
......
"""
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 ...@@ -8,7 +8,6 @@ from bok_choy.promise import EmptyPromise, fulfill
from .course_page import CoursePage from .course_page import CoursePage
from .unit import UnitPage from .unit import UnitPage
class CourseOutlineContainer(object): class CourseOutlineContainer(object):
""" """
A mixin to a CourseOutline page object that adds the ability to load A mixin to a CourseOutline page object that adds the ability to load
...@@ -18,11 +17,13 @@ class CourseOutlineContainer(object): ...@@ -18,11 +17,13 @@ class CourseOutlineContainer(object):
""" """
CHILD_CLASS = None CHILD_CLASS = None
def child(self, title): def child(self, title, child_class=None):
return self.CHILD_CLASS( if not child_class:
child_class = self.CHILD_CLASS
return child_class(
self.browser, self.browser,
self.q(css=self.CHILD_CLASS.BODY_SELECTOR).filter( self.q(css=child_class.BODY_SELECTOR).filter(
SubQuery(css=self.CHILD_CLASS.NAME_SELECTOR).filter(text=title) SubQuery(css=child_class.NAME_SELECTOR).filter(text=title)
)[0]['data-locator'] )[0]['data-locator']
) )
......
...@@ -7,6 +7,7 @@ from bok_choy.query import SubQuery ...@@ -7,6 +7,7 @@ from bok_choy.query import SubQuery
from bok_choy.promise import EmptyPromise, fulfill from bok_choy.promise import EmptyPromise, fulfill
from . import BASE_URL from . import BASE_URL
from .container import ContainerPage
class UnitPage(PageObject): class UnitPage(PageObject):
...@@ -25,9 +26,11 @@ class UnitPage(PageObject): ...@@ -25,9 +26,11 @@ class UnitPage(PageObject):
def is_browser_on_page(self): def is_browser_on_page(self):
# Wait until all components have been loaded # 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 ( return (
self.is_css_present('body.view-unit') and 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 @property
...@@ -105,3 +108,10 @@ class Component(PageObject): ...@@ -105,3 +108,10 @@ class Component(PageObject):
@property @property
def editor_selector(self): def editor_selector(self):
return self._bounded_selector('.xblock-studio_view') 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): ...@@ -153,9 +153,11 @@ class XBlockAcidBase(WebAppTest):
""" """
self.outline.visit() 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.init_fn_passed)
self.assertTrue(acid_block.child_tests_passed) self.assertTrue(acid_block.child_tests_passed)
self.assertTrue(acid_block.resource_url_passed) self.assertTrue(acid_block.resource_url_passed)
...@@ -164,13 +166,16 @@ class XBlockAcidBase(WebAppTest): ...@@ -164,13 +166,16 @@ class XBlockAcidBase(WebAppTest):
self.assertTrue(acid_block.scope_passed('preferences')) self.assertTrue(acid_block.scope_passed('preferences'))
self.assertTrue(acid_block.scope_passed('user_info')) 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): def test_acid_block_editor(self):
""" """
Verify that all expected acid block tests pass in studio preview Verify that all expected acid block tests pass in studio preview
""" """
self.outline.visit() 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() unit.edit_draft()
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment