From fcc0231d4dc0649d6f6415a68cf03da2bc932307 Mon Sep 17 00:00:00 2001 From: Andy Armstrong Date: Tue, 4 Feb 2014 18:18:42 -0500 Subject: [PATCH] Add new container page that can display nested xblocks This is the changes for STUD-1244, which introduces the ability for Studio to display arbitrarily nested xblocks. In this change, a new container page is introduced which can display nested xblocks. In particular, the xblock type of 'vertical' is special cased to be shown inline as a collapsible section. The unit page is mostly unchanged, except that container xblock's are shown as a link to their container page, rather than being shown inline. --- CHANGELOG.rst | 4 +- .../contentstore/tests/test_contentstore.py | 6 +- cms/djangoapps/contentstore/tests/utils.py | 6 - .../contentstore/views/component.py | 68 ++++++----- cms/djangoapps/contentstore/views/helpers.py | 64 ++++++++++ cms/djangoapps/contentstore/views/item.py | 54 ++++++--- cms/djangoapps/contentstore/views/preview.py | 50 ++++++-- .../views/tests/test_container.py | 33 ++++++ .../contentstore/views/tests/test_helpers.py | 44 +++++++ .../contentstore/views/tests/test_item.py | 61 +++++++++- .../contentstore/views/tests/test_preview.py | 2 +- cms/static/coffee/spec/main.coffee | 1 + .../coffee/spec/views/module_edit_spec.coffee | 10 +- .../coffee/src/views/module_edit.coffee | 38 ++---- cms/static/js/models/xblock_info.js | 16 +++ cms/static/js/spec/views/baseview_spec.js | 110 +++++++++++------- cms/static/js/spec/views/xblock_spec.js | 92 +++++++++++++++ cms/static/js/views/baseview.js | 83 +++++++------ cms/static/js/views/xblock.js | 86 ++++++++++++++ cms/static/sass/elements/_controls.scss | 1 - cms/static/sass/elements/_icons.scss | 2 +- cms/static/sass/elements/_xblocks.scss | 7 +- cms/static/sass/views/_container.scss | 33 +++++- cms/static/sass/views/_unit.scss | 34 +++++- cms/templates/container.html | 91 +++++++++++++++ cms/templates/edit-tabs.html | 26 +++-- .../js/mock/mock-collapsible-view.underscore | 4 + cms/templates/js/mock/mock-xblock.underscore | 17 +++ cms/templates/studio_vertical_wrapper.html | 24 ++++ cms/templates/studio_xblock_wrapper.html | 43 +++++++ cms/templates/unit.html | 6 +- .../unit_container_xblock_component.html | 25 ++++ cms/templates/ux/reference/unit.html | 1 + cms/urls.py | 1 + .../test/acceptance/pages/studio/container.py | 74 ++++++++++++ .../test/acceptance/pages/studio/overview.py | 11 +- common/test/acceptance/pages/studio/unit.py | 12 +- common/test/acceptance/tests/test_studio.py | 11 +- 38 files changed, 1043 insertions(+), 208 deletions(-) create mode 100644 cms/djangoapps/contentstore/views/tests/test_container.py create mode 100644 cms/djangoapps/contentstore/views/tests/test_helpers.py create mode 100644 cms/static/js/models/xblock_info.js create mode 100644 cms/static/js/spec/views/xblock_spec.js create mode 100644 cms/static/js/views/xblock.js create mode 100644 cms/templates/container.html create mode 100644 cms/templates/js/mock/mock-collapsible-view.underscore create mode 100644 cms/templates/js/mock/mock-xblock.underscore create mode 100644 cms/templates/studio_vertical_wrapper.html create mode 100644 cms/templates/studio_xblock_wrapper.html create mode 100644 cms/templates/unit_container_xblock_component.html create mode 100644 common/test/acceptance/pages/studio/container.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 00ab686c20..a8cc6d97ca 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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. diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index ccf5626561..f41a5fe046 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -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) diff --git a/cms/djangoapps/contentstore/tests/utils.py b/cms/djangoapps/contentstore/tests/utils.py index 39acda3d8a..6db416f04b 100644 --- a/cms/djangoapps/contentstore/tests/utils.py +++ b/cms/djangoapps/contentstore/tests/utils.py @@ -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) diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 840a41d0ca..c1338d6230 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -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): """ diff --git a/cms/djangoapps/contentstore/views/helpers.py b/cms/djangoapps/contentstore/views/helpers.py index a10a489c9a..f351f481f5 100644 --- a/cms/djangoapps/contentstore/views/helpers.py +++ b/cms/djangoapps/contentstore/views/helpers.py @@ -1,6 +1,9 @@ +import logging + from django.http import HttpResponse from django.shortcuts import redirect from edxmako.shortcuts import render_to_string, render_to_response +from xmodule.modulestore.django import loc_mapper, modulestore __all__ = ['edge', 'event', 'landing'] @@ -35,3 +38,64 @@ def _xmodule_recurse(item, action): _xmodule_recurse(child, action) action(item) + + +def get_parent_xblock(xblock): + """ + Returns the xblock that is the parent of the specified xblock, or None if it has no parent. + """ + locator = xblock.location + parent_locations = modulestore().get_parent_locations(locator, None) + + if len(parent_locations) == 0: + return None + elif len(parent_locations) > 1: + logging.error('Multiple parents have been found for %s', unicode(locator)) + return modulestore().get_item(parent_locations[0]) + + +def _xblock_has_studio_page(xblock): + """ + Returns true if the specified xblock has an associated Studio page. Most xblocks do + not have their own page but are instead shown on the page of their parent. There + are a few exceptions: + 1. Courses + 2. Verticals + 3. XBlocks with children, except for: + - subsections (aka sequential blocks) + - chapters + """ + category = xblock.category + if category in ('course', 'vertical'): + return True + elif category in ('sequential', 'chapter'): + return False + elif xblock.has_children: + return True + else: + return False + + +def xblock_studio_url(xblock, course=None): + """ + Returns the Studio editing URL for the specified xblock. + """ + if not _xblock_has_studio_page(xblock): + return None + category = xblock.category + parent_xblock = get_parent_xblock(xblock) + if parent_xblock: + parent_category = parent_xblock.category + else: + parent_category = None + if category == 'course': + prefix = 'course' + elif category == 'vertical' and parent_category == 'sequential': + prefix = 'unit' # only show the unit page for verticals directly beneath a subsection + else: + prefix = 'container' + course_id = None + if course: + course_id = course.location.course_id + locator = loc_mapper().translate_location(course_id, xblock.location) + return locator.url_reverse(prefix) diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 47b981b728..d9734773b0 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -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 diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index e236d15981..8307c88d64 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -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)})) diff --git a/cms/djangoapps/contentstore/views/tests/test_container.py b/cms/djangoapps/contentstore/views/tests/test_container.py new file mode 100644 index 0000000000..b1a6faf96f --- /dev/null +++ b/cms/djangoapps/contentstore/views/tests/test_container.py @@ -0,0 +1,33 @@ +""" +Unit tests for the container view. +""" + +from contentstore.tests.utils import CourseTestCase +from contentstore.views.helpers import xblock_studio_url +from xmodule.modulestore.tests.factories import ItemFactory + + +class ContainerViewTestCase(CourseTestCase): + """ + Unit tests for the container view. + """ + + def setUp(self): + super(ContainerViewTestCase, self).setUp() + self.chapter = ItemFactory.create(parent_location=self.course.location, + category='chapter', display_name="Week 1") + self.sequential = ItemFactory.create(parent_location=self.chapter.location, + category='sequential', display_name="Lesson 1") + self.vertical = ItemFactory.create(parent_location=self.sequential.location, + category='vertical', display_name='Unit') + self.child_vertical = ItemFactory.create(parent_location=self.vertical.location, + category='vertical', display_name='Child Vertical') + self.video = ItemFactory.create(parent_location=self.child_vertical.location, + category="video", display_name="My Video") + + def test_container_html(self): + url = xblock_studio_url(self.child_vertical) + resp = self.client.get_html(url) + self.assertEqual(resp.status_code, 200) + html = resp.content + self.assertIn('
', html) diff --git a/cms/djangoapps/contentstore/views/tests/test_helpers.py b/cms/djangoapps/contentstore/views/tests/test_helpers.py new file mode 100644 index 0000000000..b0ca2f5529 --- /dev/null +++ b/cms/djangoapps/contentstore/views/tests/test_helpers.py @@ -0,0 +1,44 @@ +""" +Unit tests for helpers.py. +""" + +from contentstore.tests.utils import CourseTestCase +from contentstore.views.helpers import xblock_studio_url +from xmodule.modulestore.tests.factories import ItemFactory + + +class HelpersTestCase(CourseTestCase): + """ + Unit tests for helpers.py. + """ + def test_xblock_studio_url(self): + # Verify course URL + self.assertEqual(xblock_studio_url(self.course), + u'/course/MITx.999.Robot_Super_Course/branch/published/block/Robot_Super_Course') + + # Verify chapter URL + chapter = ItemFactory.create(parent_location=self.course.location, category='chapter', + display_name="Week 1") + self.assertIsNone(xblock_studio_url(chapter)) + + # Verify lesson URL + sequential = ItemFactory.create(parent_location=chapter.location, category='sequential', + display_name="Lesson 1") + self.assertIsNone(xblock_studio_url(sequential)) + + # Verify vertical URL + vertical = ItemFactory.create(parent_location=sequential.location, category='vertical', + display_name='Unit') + self.assertEqual(xblock_studio_url(vertical), + u'/unit/MITx.999.Robot_Super_Course/branch/published/block/Unit') + + # Verify child vertical URL + child_vertical = ItemFactory.create(parent_location=vertical.location, category='vertical', + display_name='Child Vertical') + self.assertEqual(xblock_studio_url(child_vertical), + u'/container/MITx.999.Robot_Super_Course/branch/published/block/Child_Vertical') + + # Verify video URL + video = ItemFactory.create(parent_location=child_vertical.location, category="video", + display_name="My Video") + self.assertIsNone(xblock_studio_url(video)) diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py index 931a2be012..3aa3f15ad9 100644 --- a/cms/djangoapps/contentstore/views/tests/test_item.py +++ b/cms/djangoapps/contentstore/views/tests/test_item.py @@ -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('
', html) + self.assertIn('
', 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('
', html) + self.assertIn('
', 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, diff --git a/cms/djangoapps/contentstore/views/tests/test_preview.py b/cms/djangoapps/contentstore/views/tests/test_preview.py index cb5b62c67f..afbd576c75 100644 --- a/cms/djangoapps/contentstore/views/tests/test_preview.py +++ b/cms/djangoapps/contentstore/views/tests/test_preview.py @@ -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, diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee index 705ff08238..a863492228 100644 --- a/cms/static/coffee/spec/main.coffee +++ b/cms/static/coffee/spec/main.coffee @@ -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 diff --git a/cms/static/coffee/spec/views/module_edit_spec.coffee b/cms/static/coffee/spec/views/module_edit_spec.coffee index 377fdffb89..ccf22fab40 100644 --- a/cms/static/coffee/spec/views/module_edit_spec.coffee +++ b/cms/static/coffee/spec/views/module_edit_spec.coffee @@ -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() diff --git a/cms/static/coffee/src/views/module_edit.coffee b/cms/static/coffee/src/views/module_edit.coffee index d61ed7f95e..e547a43c47 100644 --- a/cms/static/coffee/src/views/module_edit.coffee +++ b/cms/static/coffee/src/views/module_edit.coffee @@ -1,8 +1,8 @@ -define ["backbone", "jquery", "underscore", "gettext", "xblock/runtime.v1", - "js/views/feedback_notification", "js/views/metadata", "js/collections/metadata" - "js/utils/modal", "jquery.inputnumber", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"], -(Backbone, $, _, gettext, XBlock, NotificationView, MetadataView, MetadataCollection, ModalUtils) -> - class ModuleEdit extends Backbone.View +define ["jquery", "underscore", "gettext", "xblock/runtime.v1", + "js/views/xblock", "js/views/feedback_notification", "js/views/metadata", "js/collections/metadata" + "js/utils/modal", "jquery.inputnumber"], +($, _, gettext, XBlock, XBlockView, NotificationView, MetadataView, MetadataCollection, ModalUtils) -> + class ModuleEdit extends XBlockView tagName: 'li' className: 'component' editorMode: 'editor-mode' @@ -79,31 +79,9 @@ define ["backbone", "jquery", "underscore", "gettext", "xblock/runtime.v1", url: "#{decodeURIComponent(@model.url())}/#{viewName}" type: 'GET' headers: - Accept: 'application/x-fragment+json' - success: (data) => - $(target).html(data.html) - - for value in data.resources - do (value) => - hash = value[0] - if not window.loadedXBlockResources? - window.loadedXBlockResources = [] - - if hash not in window.loadedXBlockResources - resource = value[1] - switch resource.mimetype - when "text/css" - switch resource.kind - when "text" then $('head').append("") - when "url" then $('head').append("") - when "application/javascript" - switch resource.kind - when "text" then $('head').append("") - 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() ) diff --git a/cms/static/js/models/xblock_info.js b/cms/static/js/models/xblock_info.js new file mode 100644 index 0000000000..ed62a50bae --- /dev/null +++ b/cms/static/js/models/xblock_info.js @@ -0,0 +1,16 @@ +define(["backbone", "js/utils/module"], function(Backbone, ModuleUtils) { + var XBlockInfo = Backbone.Model.extend({ + + urlRoot: ModuleUtils.urlRoot, + + defaults: { + "id": null, + "display_name": null, + "category": null, + "is_draft": null, + "is_container": null, + "children": [] + } + }); + return XBlockInfo; +}); \ No newline at end of file diff --git a/cms/static/js/spec/views/baseview_spec.js b/cms/static/js/spec/views/baseview_spec.js index d3cbbcebc1..4d88992a3e 100644 --- a/cms/static/js/spec/views/baseview_spec.js +++ b/cms/static/js/spec/views/baseview_spec.js @@ -1,50 +1,80 @@ -define( - [ - "jquery", "underscore", - "js/views/baseview", - "js/utils/handle_iframe_binding", - "sinon" - ], -function ($, _, BaseView, IframeBinding, sinon) { +define(["jquery", "underscore", "js/views/baseview", "js/utils/handle_iframe_binding", "sinon"], + function ($, _, BaseView, IframeBinding, sinon) { - describe("BaseView check", function () { - var baseView; - var iframeBinding_spy; + describe("BaseView", function() { + var baseViewPrototype; - beforeEach(function () { - iframeBinding_spy = sinon.spy(IframeBinding, "iframeBinding"); - baseView = BaseView.prototype; + describe("BaseView rendering", function () { + var iframeBinding_spy; - spyOn(baseView, 'initialize'); - spyOn(baseView, 'beforeRender'); - spyOn(baseView, 'render'); - spyOn(baseView, 'afterRender').andCallThrough(); - }); + beforeEach(function () { + baseViewPrototype = BaseView.prototype; + iframeBinding_spy = sinon.spy(IframeBinding, "iframeBinding"); - afterEach(function () { - iframeBinding_spy.restore(); - }); + spyOn(baseViewPrototype, 'initialize'); + spyOn(baseViewPrototype, 'beforeRender'); + spyOn(baseViewPrototype, 'render').andCallThrough(); + spyOn(baseViewPrototype, 'afterRender').andCallThrough(); + }); - it('calls before and after render functions when render of baseview is called', function () { - var baseview_temp = new BaseView() - baseview_temp.render(); + afterEach(function () { + iframeBinding_spy.restore(); + }); - expect(baseView.initialize).toHaveBeenCalled(); - expect(baseView.beforeRender).toHaveBeenCalled(); - expect(baseView.render).toHaveBeenCalled(); - expect(baseView.afterRender).toHaveBeenCalled(); - }); + it('calls before and after render functions when render of baseview is called', function () { + var baseView = new BaseView(); + baseView.render(); - it('calls iframeBinding function when afterRender of baseview is called', function () { - var baseview_temp = new BaseView() - baseview_temp.render(); - expect(baseView.afterRender).toHaveBeenCalled(); - expect(iframeBinding_spy.called).toEqual(true); + expect(baseViewPrototype.initialize).toHaveBeenCalled(); + expect(baseViewPrototype.beforeRender).toHaveBeenCalled(); + expect(baseViewPrototype.render).toHaveBeenCalled(); + expect(baseViewPrototype.afterRender).toHaveBeenCalled(); + }); - //check calls count of iframeBinding function - expect(iframeBinding_spy.callCount).toBe(1); - IframeBinding.iframeBinding(); - expect(iframeBinding_spy.callCount).toBe(2); + it('calls iframeBinding function when afterRender of baseview is called', function () { + var baseView = new BaseView(); + baseView.render(); + expect(baseViewPrototype.afterRender).toHaveBeenCalled(); + expect(iframeBinding_spy.called).toEqual(true); + + //check calls count of iframeBinding function + expect(iframeBinding_spy.callCount).toBe(1); + IframeBinding.iframeBinding(); + expect(iframeBinding_spy.callCount).toBe(2); + }); + }); + + describe("Expand/Collapse", function () { + var view, MockCollapsibleViewClass; + + MockCollapsibleViewClass = BaseView.extend({ + initialize: function() { + this.viewHtml = readFixtures('mock/mock-collapsible-view.underscore'); + }, + + render: function() { + this.$el.html(this.viewHtml); + } + }); + + it('hides a collapsible node when clicking on the toggle link', function () { + view = new MockCollapsibleViewClass(); + view.render(); + view.$('.ui-toggle-expansion').click(); + expect(view.$('.expand-collapse')).toHaveClass('expand'); + expect(view.$('.expand-collapse')).not.toHaveClass('collapse'); + expect(view.$('.is-collapsible')).toHaveClass('collapsed'); + }); + + it('expands a collapsible node when clicking twice on the toggle link', function () { + view = new MockCollapsibleViewClass(); + view.render(); + view.$('.ui-toggle-expansion').click(); + view.$('.ui-toggle-expansion').click(); + expect(view.$('.expand-collapse')).toHaveClass('collapse'); + expect(view.$('.expand-collapse')).not.toHaveClass('expand'); + expect(view.$('.is-collapsible')).not.toHaveClass('collapsed'); + }); + }); }); }); -}); diff --git a/cms/static/js/spec/views/xblock_spec.js b/cms/static/js/spec/views/xblock_spec.js new file mode 100644 index 0000000000..b3e05c9ca5 --- /dev/null +++ b/cms/static/js/spec/views/xblock_spec.js @@ -0,0 +1,92 @@ +define([ "jquery", "js/spec/create_sinon", "URI", "js/views/xblock", "js/models/xblock_info", + "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"], + function ($, create_sinon, URI, XBlockView, XBlockInfo) { + + describe("XBlockView", function() { + var model, xblockView, mockXBlockHtml, respondWithMockXBlockFragment; + + beforeEach(function () { + model = new XBlockInfo({ + id: 'testCourse/branch/published/block/verticalFFF', + display_name: 'Test Unit', + category: 'vertical' + }); + xblockView = new XBlockView({ + model: model + }); + }); + + mockXBlockHtml = readFixtures('mock/mock-xblock.underscore'); + + respondWithMockXBlockFragment = function(requests, response) { + var requestIndex = requests.length - 1; + create_sinon.respondWithJson(requests, response, requestIndex); + }; + + it('can render a nested xblock', function() { + var requests = create_sinon.requests(this); + xblockView.render(); + respondWithMockXBlockFragment(requests, { + html: mockXBlockHtml, + "resources": [] + }); + + expect(xblockView.$el.select('.xblock-header')).toBeTruthy(); + }); + + describe("XBlock rendering", function() { + var postXBlockRequest; + + postXBlockRequest = function(requests, resources) { + $.ajax({ + url: "test_url", + type: 'GET', + success: function(fragment) { + xblockView.renderXBlockFragment(fragment, this.$el); + } + }); + respondWithMockXBlockFragment(requests, { + html: mockXBlockHtml, + resources: resources + }); + expect(xblockView.$el.select('.xblock-header')).toBeTruthy(); + }; + + it('can render an xblock with no CSS or JavaScript', function() { + var requests = create_sinon.requests(this); + postXBlockRequest(requests, []); + }); + + it('can render an xblock with required CSS', function() { + var requests = create_sinon.requests(this), + mockCssText = "// Just a comment", + mockCssUrl = "mock.css", + headHtml; + postXBlockRequest(requests, [ + ["hash1", { mimetype: "text/css", kind: "text", data: mockCssText }], + ["hash2", { mimetype: "text/css", kind: "url", data: mockCssUrl }] + ]); + headHtml = $('head').html(); + expect(headHtml).toContain(mockCssText); + expect(headHtml).toContain(mockCssUrl); + }); + + it('can render an xblock with required JavaScript', function() { + var requests = create_sinon.requests(this); + postXBlockRequest(requests, [ + ["hash3", { mimetype: "application/javascript", kind: "text", data: "window.test = 100;" }] + ]); + expect(window.test).toBe(100); + }); + + it('can render an xblock with required HTML', function() { + var requests = create_sinon.requests(this), + mockHeadTag = "Test Title"; + postXBlockRequest(requests, [ + ["hash4", { mimetype: "text/html", placement: "head", data: mockHeadTag }] + ]); + expect($('head').html()).toContain(mockHeadTag); + }); + }); + }); + }); diff --git a/cms/static/js/views/baseview.js b/cms/static/js/views/baseview.js index 428070e7c1..e07bf6dddf 100644 --- a/cms/static/js/views/baseview.js +++ b/cms/static/js/views/baseview.js @@ -1,44 +1,53 @@ -define( - [ - 'jquery', - 'underscore', - 'backbone', - "js/utils/handle_iframe_binding" - ], +define(["jquery", "underscore", "backbone", "js/utils/handle_iframe_binding"], function ($, _, Backbone, IframeUtils) { - /* This view is extended from backbone with custom functions 'beforeRender' and 'afterRender'. It allows other - views, which extend from it to access these custom functions. 'afterRender' function of BaseView calls a utility - function 'iframeBinding' which modifies iframe src urls on a page so that they are rendered as part of the DOM. - Other common functions which need to be run before/after can also be added here. - */ + /* + This view is extended from backbone to provide useful functionality for all Studio views. + This functionality includes: + - automatic expand and collapse of elements with the 'ui-toggle-expansion' class specified + - additional control of rendering by overriding 'beforeRender' or 'afterRender' - var BaseView = Backbone.View.extend({ - //override the constructor function - constructor: function(options) { - _.bindAll(this, 'beforeRender', 'render', 'afterRender'); - var _this = this; - this.render = _.wrap(this.render, function (render) { - _this.beforeRender(); - render(); - _this.afterRender(); - return _this; - }); + Note: the default 'afterRender' function calls a utility function 'iframeBinding' which modifies + iframe src urls on a page so that they are rendered as part of the DOM. + */ - //call Backbone's own constructor - Backbone.View.prototype.constructor.apply(this, arguments); - }, + var BaseView = Backbone.View.extend({ + events: { + "click .ui-toggle-expansion": "toggleExpandCollapse" + }, - beforeRender: function () { - }, + //override the constructor function + constructor: function(options) { + _.bindAll(this, 'beforeRender', 'render', 'afterRender'); + var _this = this; + this.render = _.wrap(this.render, function (render) { + _this.beforeRender(); + render(); + _this.afterRender(); + return _this; + }); - render: function () { - return this; - }, + //call Backbone's own constructor + Backbone.View.prototype.constructor.apply(this, arguments); + }, - afterRender: function () { - IframeUtils.iframeBinding(this); - } + beforeRender: function() { + }, + + render: function() { + return this; + }, + + afterRender: function() { + IframeUtils.iframeBinding(this); + }, + + toggleExpandCollapse: function(event) { + var target = $(event.target); + event.preventDefault(); + target.closest('.expand-collapse').toggleClass('expand').toggleClass('collapse'); + target.closest('.is-collapsible, .window').toggleClass('collapsed'); + } + }); + + return BaseView; }); - - return BaseView; -}); \ No newline at end of file diff --git a/cms/static/js/views/xblock.js b/cms/static/js/views/xblock.js new file mode 100644 index 0000000000..74fcf6464a --- /dev/null +++ b/cms/static/js/views/xblock.js @@ -0,0 +1,86 @@ +define(["jquery", "underscore", "js/views/baseview", "xblock/runtime.v1"], + function ($, _, BaseView, XBlock) { + + var XBlockView = BaseView.extend({ + // takes XBlockInfo as a model + + initialize: function() { + BaseView.prototype.initialize.call(this); + this.view = this.options.view; + }, + + render: function() { + var self = this, + view = this.view; + return $.ajax({ + url: decodeURIComponent(this.model.url()) + "/" + view, + type: 'GET', + headers: { + Accept: 'application/json' + }, + success: function(fragment) { + var wrapper = self.$el, + xblock; + self.renderXBlockFragment(fragment, wrapper); + xblock = self.$('.xblock').first(); + XBlock.initializeBlock(xblock); + } + }); + }, + + + /** + * Renders an xblock fragment into the specifed element. The fragment has two attributes: + * html: the HTML to be rendered + * resources: any JavaScript or CSS resources that the HTML depends upon + * @param fragment The fragment returned from the xblock_handler + * @param element The element into which to render the fragment (defaults to this.$el) + */ + renderXBlockFragment: function(fragment, element) { + var applyResource, i, len, resources, resource; + if (!element) { + element = this.$el; + } + + applyResource = function(value) { + var hash, resource, head; + hash = value[0]; + if (!window.loadedXBlockResources) { + window.loadedXBlockResources = []; + } + if (_.indexOf(window.loadedXBlockResources, hash) < 0) { + resource = value[1]; + head = $('head'); + if (resource.mimetype === "text/css") { + if (resource.kind === "text") { + head.append(""); + } else if (resource.kind === "url") { + head.append(""); + } + } else if (resource.mimetype === "application/javascript") { + if (resource.kind === "text") { + head.append(""); + } 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(); diff --git a/cms/static/sass/elements/_controls.scss b/cms/static/sass/elements/_controls.scss index b76eee7d94..f96977be71 100644 --- a/cms/static/sass/elements/_controls.scss +++ b/cms/static/sass/elements/_controls.scss @@ -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); diff --git a/cms/static/sass/elements/_icons.scss b/cms/static/sass/elements/_icons.scss index 1ab50b5a13..fca7087902 100644 --- a/cms/static/sass/elements/_icons.scss +++ b/cms/static/sass/elements/_icons.scss @@ -6,7 +6,7 @@ } [class^="icon-"] { - + font-style: normal; } .icon-inline { diff --git a/cms/static/sass/elements/_xblocks.scss b/cms/static/sass/elements/_xblocks.scss index df863ebc6c..1fccbf9cb8 100644 --- a/cms/static/sass/elements/_xblocks.scss +++ b/cms/static/sass/elements/_xblocks.scss @@ -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 @@ } } } - diff --git a/cms/static/sass/views/_container.scss b/cms/static/sass/views/_container.scss index d3eeb08514..4c50103b81 100644 --- a/cms/static/sass/views/_container.scss +++ b/cms/static/sass/views/_container.scss @@ -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; + } +} diff --git a/cms/static/sass/views/_unit.scss b/cms/static/sass/views/_unit.scss index d90a619a18..0ace346834 100644 --- a/cms/static/sass/views/_unit.scss +++ b/cms/static/sass/views/_unit.scss @@ -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); +} diff --git a/cms/templates/container.html b/cms/templates/container.html new file mode 100644 index 0000000000..39ef7cfd13 --- /dev/null +++ b/cms/templates/container.html @@ -0,0 +1,91 @@ +<%inherit file="base.html" /> +<%! +import json + +from contentstore.views.helpers import xblock_studio_url +from django.utils.translation import ugettext as _ +%> +<%block name="title">${_("Container")} +<%block name="bodyclass">is-signedin course container view-container + +<%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, +}; +%> + + + + +<%block name="content"> + + +
+
+

+ + <% + parent_url = xblock_studio_url(parent_xblock, context_course) + %> + % if parent_url: + ${parent_xblock.display_name | h} + % endif + ${xblock.display_name | h} + +

+ + +
+
+ +
+
+
+ +
+
+
+ +
+
+
+ + diff --git a/cms/templates/edit-tabs.html b/cms/templates/edit-tabs.html index d0e8f7b86b..e54694f6a9 100644 --- a/cms/templates/edit-tabs.html +++ b/cms/templates/edit-tabs.html @@ -8,19 +8,21 @@ <%block name="bodyclass">is-signedin course view-static-pages <%block name="jsextra"> - diff --git a/cms/templates/js/mock/mock-collapsible-view.underscore b/cms/templates/js/mock/mock-collapsible-view.underscore new file mode 100644 index 0000000000..78f55f2784 --- /dev/null +++ b/cms/templates/js/mock/mock-collapsible-view.underscore @@ -0,0 +1,4 @@ +
+ Expand/Collapse +
Mock Content
+
diff --git a/cms/templates/js/mock/mock-xblock.underscore b/cms/templates/js/mock/mock-xblock.underscore new file mode 100644 index 0000000000..4e7dd6e147 --- /dev/null +++ b/cms/templates/js/mock/mock-xblock.underscore @@ -0,0 +1,17 @@ +
+
+ Mock XBlock +
+
+
    +
  • No Actions
  • +
+
+
+
+
+

Mock XBlock

+
+
diff --git a/cms/templates/studio_vertical_wrapper.html b/cms/templates/studio_vertical_wrapper.html new file mode 100644 index 0000000000..774b49bf95 --- /dev/null +++ b/cms/templates/studio_vertical_wrapper.html @@ -0,0 +1,24 @@ +<%! from django.utils.translation import ugettext as _ %> +% if xblock.location != xblock_context['root_xblock'].location: +
+% endif +
+
+ + + ${_('Expand or Collapse')} + + ${xblock.display_name | h} +
+
+
    +
  • ${_('No Actions')}
  • +
+
+
+
+${content} +
+% if xblock.location != xblock_context['root_xblock'].location: +
+% endif diff --git a/cms/templates/studio_xblock_wrapper.html b/cms/templates/studio_xblock_wrapper.html new file mode 100644 index 0000000000..870c2509b4 --- /dev/null +++ b/cms/templates/studio_xblock_wrapper.html @@ -0,0 +1,43 @@ +<%! from django.utils.translation import ugettext as _ %> +% if xblock.location != xblock_context['root_xblock'].location: + % if xblock.has_children: +
+ % else: +
+ % endif +% endif +
+
+ ${xblock.display_name | h} +
+
+ +
+
+
+${content} +
+% if xblock.location != xblock_context['root_xblock'].location: +
+% endif diff --git a/cms/templates/unit.html b/cms/templates/unit.html index 40aaee6b2c..3c8812510c 100644 --- a/cms/templates/unit.html +++ b/cms/templates/unit.html @@ -11,7 +11,8 @@ from xmodule.modulestore.django import loc_mapper <%block name="jsextra">