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,
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):
"""
......
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)}))
......
"""
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):
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()
......
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()
)
......
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(
[
"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();
});
define(["jquery", "underscore", "js/views/baseview", "js/utils/handle_iframe_binding", "sinon"],
function ($, _, BaseView, IframeBinding, sinon) {
afterEach(function () {
iframeBinding_spy.restore();
});
describe("BaseView", function() {
var baseViewPrototype;
it('calls before and after render functions when render of baseview is called', function () {
var baseview_temp = new BaseView()
baseview_temp.render();
describe("BaseView rendering", function () {
var iframeBinding_spy;
expect(baseView.initialize).toHaveBeenCalled();
expect(baseView.beforeRender).toHaveBeenCalled();
expect(baseView.render).toHaveBeenCalled();
expect(baseView.afterRender).toHaveBeenCalled();
});
beforeEach(function () {
baseViewPrototype = BaseView.prototype;
iframeBinding_spy = sinon.spy(IframeBinding, "iframeBinding");
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 () {
var baseview_temp = new BaseView()
baseview_temp.render();
expect(baseView.afterRender).toHaveBeenCalled();
expect(iframeBinding_spy.called).toEqual(true);
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');
});
//check calls count of iframeBinding function
expect(iframeBinding_spy.callCount).toBe(1);
IframeBinding.iframeBinding();
expect(iframeBinding_spy.callCount).toBe(2);
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');
});
});
});
});
});
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(
[
'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.
*/
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;
});
//call Backbone's own constructor
Backbone.View.prototype.constructor.apply(this, arguments);
},
beforeRender: function () {
},
render: function () {
return this;
},
afterRender: function () {
IframeUtils.iframeBinding(this);
}
});
/*
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'
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.
*/
var BaseView = Backbone.View.extend({
events: {
"click .ui-toggle-expansion": "toggleExpandCollapse"
},
//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;
});
//call Backbone's own constructor
Backbone.View.prototype.constructor.apply(this, arguments);
},
return BaseView;
});
\ No newline at end of file
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;
});
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);
}
<%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>
......
<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
<%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">
......
<%!
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'),
......
"""
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()
......
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