Commit 541d20ef by Andy Armstrong

Allow creation of components on container page

This commit implements STUD-1490, allowing creation of components
on the container page. It also enables the delete and duplicate
buttons now that new content can be created that would benefit.

Note that it also creates shared functionality for adding components,
and refactors the unit page to use it too.
parent 5752312b
...@@ -7,6 +7,8 @@ the top. Include a label indicating the component affected. ...@@ -7,6 +7,8 @@ the top. Include a label indicating the component affected.
Blades: Tolerance expressed in percentage now computes correctly. BLD-522. Blades: Tolerance expressed in percentage now computes correctly. BLD-522.
Studio: Support add, delete and duplicate on the container page. STUD-1490.
Studio: Add drag-and-drop support to the container page. STUD-1309. Studio: Add drag-and-drop support to the container page. STUD-1309.
Common: Add extensible third-party auth module. Common: Add extensible third-party auth module.
...@@ -20,7 +22,8 @@ LMS: Switch default instructor dashboard to the new (formerly "beta") ...@@ -20,7 +22,8 @@ LMS: Switch default instructor dashboard to the new (formerly "beta")
Blades: Handle situation if no response were sent from XQueue to LMS in Matlab Blades: Handle situation if no response were sent from XQueue to LMS in Matlab
problem after Run Code button press. BLD-994. problem after Run Code button press. BLD-994.
Blades: Set initial video quality to large instead of default to avoid automatic switch to HD when iframe resizes. BLD-981. Blades: Set initial video quality to large instead of default to avoid automatic
switch to HD when iframe resizes. BLD-981.
Blades: Add an upload button for authors to provide students with an option to Blades: Add an upload button for authors to provide students with an option to
download a handout associated with a video (of arbitrary file format). BLD-1000. download a handout associated with a video (of arbitrary file format). BLD-1000.
......
...@@ -490,12 +490,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ...@@ -490,12 +490,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
Tests the ajax callback to render an XModule Tests the ajax callback to render an XModule
""" """
resp = self._test_preview(Location('i4x', 'edX', 'toy', 'vertical', 'vertical_test', None), 'container_preview') 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. self.assertContains(resp, '/branch/draft/block/sample_video')
# Ultimately, these must be converted to new locators. self.assertContains(resp, '/branch/draft/block/separate_file_video')
self.assertContains(resp, 'i4x://edX/toy/video/sample_video') self.assertContains(resp, '/branch/draft/block/video_with_end_time')
self.assertContains(resp, 'i4x://edX/toy/video/separate_file_video') self.assertContains(resp, '/branch/draft/block/T1_changemind_poll_foo_2')
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, view_name): def _test_preview(self, location, view_name):
""" Preview test case. """ """ Preview test case. """
......
...@@ -164,70 +164,7 @@ def unit_handler(request, tag=None, package_id=None, branch=None, version_guid=N ...@@ -164,70 +164,7 @@ def unit_handler(request, tag=None, package_id=None, branch=None, version_guid=N
except ItemNotFoundError: except ItemNotFoundError:
return HttpResponseBadRequest() return HttpResponseBadRequest()
component_templates = defaultdict(list) component_templates = _get_component_templates(course)
for category in COMPONENT_TYPES:
component_class = _load_mixed_class(category)
# add the default template
# TODO: Once mixins are defined per-application, rather than per-runtime,
# this should use a cms mixed-in class. (cpennington)
if hasattr(component_class, 'display_name'):
display_name = component_class.display_name.default or 'Blank'
else:
display_name = 'Blank'
component_templates[category].append((
display_name,
category,
False, # No defaults have markdown (hardcoded current default)
None # no boilerplate for overrides
))
# add boilerplates
if hasattr(component_class, 'templates'):
for template in component_class.templates():
filter_templates = getattr(component_class, 'filter_templates', None)
if not filter_templates or filter_templates(template, course):
component_templates[category].append((
template['metadata'].get('display_name'),
category,
template['metadata'].get('markdown') is not None,
template.get('template_id')
))
# Check if there are any advanced modules specified in the course policy.
# These modules should be specified as a list of strings, where the strings
# are the names of the modules in ADVANCED_COMPONENT_TYPES that should be
# enabled for the course.
course_advanced_keys = course.advanced_modules
# Set component types according to course policy file
if isinstance(course_advanced_keys, list):
for category in course_advanced_keys:
if category in ADVANCED_COMPONENT_TYPES:
# Do I need to allow for boilerplates or just defaults on the
# class? i.e., can an advanced have more than one entry in the
# menu? one for default and others for prefilled boilerplates?
try:
component_class = _load_mixed_class(category)
component_templates['advanced'].append(
(
component_class.display_name.default or category,
category,
False,
None # don't override default data
)
)
except PluginMissingError:
# dhm: I got this once but it can happen any time the
# course author configures an advanced component which does
# not exist on the server. This code here merely
# prevents any authors from trying to instantiate the
# non-existent component type by not showing it in the menu
pass
else:
log.error(
"Improper format for course advanced keys! %s",
course_advanced_keys
)
xblocks = item.get_children() xblocks = item.get_children()
locators = [ locators = [
...@@ -274,7 +211,7 @@ def unit_handler(request, tag=None, package_id=None, branch=None, version_guid=N ...@@ -274,7 +211,7 @@ def unit_handler(request, tag=None, package_id=None, branch=None, version_guid=N
'unit': item, 'unit': item,
'unit_locator': locator, 'unit_locator': locator,
'locators': locators, 'locators': locators,
'component_templates': component_templates, 'component_templates': json.dumps(component_templates),
'draft_preview_link': preview_lms_link, 'draft_preview_link': preview_lms_link,
'published_preview_link': lms_link, 'published_preview_link': lms_link,
'subsection': containing_subsection, 'subsection': containing_subsection,
...@@ -312,6 +249,7 @@ def container_handler(request, tag=None, package_id=None, branch=None, version_g ...@@ -312,6 +249,7 @@ def container_handler(request, tag=None, package_id=None, branch=None, version_g
except ItemNotFoundError: except ItemNotFoundError:
return HttpResponseBadRequest() return HttpResponseBadRequest()
component_templates = _get_component_templates(course)
ancestor_xblocks = [] ancestor_xblocks = []
parent = get_parent_xblock(xblock) parent = get_parent_xblock(xblock)
while parent and parent.category != 'sequential': while parent and parent.category != 'sequential':
...@@ -329,11 +267,106 @@ def container_handler(request, tag=None, package_id=None, branch=None, version_g ...@@ -329,11 +267,106 @@ def container_handler(request, tag=None, package_id=None, branch=None, version_g
'unit': unit, 'unit': unit,
'unit_publish_state': unit_publish_state, 'unit_publish_state': unit_publish_state,
'ancestor_xblocks': ancestor_xblocks, 'ancestor_xblocks': ancestor_xblocks,
'component_templates': json.dumps(component_templates),
}) })
else: else:
return HttpResponseBadRequest("Only supports html requests") return HttpResponseBadRequest("Only supports html requests")
def _get_component_templates(course):
"""
Returns the applicable component templates that can be used by the specified course.
"""
def create_template_dict(name, cat, boilerplate_name=None, is_common=False):
"""
Creates a component template dict.
Parameters
display_name: the user-visible name of the component
category: the type of component (problem, html, etc.)
boilerplate_name: name of boilerplate for filling in default values. May be None.
is_common: True if "common" problem, False if "advanced". May be None, as it is only used for problems.
"""
return {
"display_name": name,
"category": cat,
"boilerplate_name": boilerplate_name,
"is_common": is_common
}
component_templates = []
# The component_templates array is in the order of "advanced" (if present), followed
# by the components in the order listed in COMPONENT_TYPES.
for category in COMPONENT_TYPES:
templates_for_category = []
component_class = _load_mixed_class(category)
# add the default template
# TODO: Once mixins are defined per-application, rather than per-runtime,
# this should use a cms mixed-in class. (cpennington)
if hasattr(component_class, 'display_name'):
display_name = component_class.display_name.default or 'Blank'
else:
display_name = 'Blank'
templates_for_category.append(create_template_dict(display_name, category))
# add boilerplates
if hasattr(component_class, 'templates'):
for template in component_class.templates():
filter_templates = getattr(component_class, 'filter_templates', None)
if not filter_templates or filter_templates(template, course):
templates_for_category.append(
create_template_dict(
template['metadata'].get('display_name'),
category,
template.get('template_id'),
template['metadata'].get('markdown') is not None
)
)
component_templates.append({"type": category, "templates": templates_for_category})
# Check if there are any advanced modules specified in the course policy.
# These modules should be specified as a list of strings, where the strings
# are the names of the modules in ADVANCED_COMPONENT_TYPES that should be
# enabled for the course.
course_advanced_keys = course.advanced_modules
advanced_component_templates = {"type": "advanced", "templates": []}
# Set component types according to course policy file
if isinstance(course_advanced_keys, list):
for category in course_advanced_keys:
if category in ADVANCED_COMPONENT_TYPES:
# boilerplates not supported for advanced components
try:
component_class = _load_mixed_class(category)
advanced_component_templates['templates'].append(
create_template_dict(
component_class.display_name.default or category,
category
)
)
except PluginMissingError:
# dhm: I got this once but it can happen any time the
# course author configures an advanced component which does
# not exist on the server. This code here merely
# prevents any authors from trying to instantiate the
# non-existent component type by not showing it in the menu
log.warning(
"Advanced component %s does not exist. It will not be added to the Studio new component menu.",
category
)
pass
else:
log.error(
"Improper format for course advanced keys! %s",
course_advanced_keys
)
if len(advanced_component_templates['templates']) > 0:
component_templates.insert(0, advanced_component_templates)
return component_templates
@login_required @login_required
def _get_item_in_course(request, locator): def _get_item_in_course(request, locator):
""" """
......
...@@ -8,7 +8,9 @@ from xmodule.modulestore.django import loc_mapper, modulestore ...@@ -8,7 +8,9 @@ from xmodule.modulestore.django import loc_mapper, modulestore
__all__ = ['edge', 'event', 'landing'] __all__ = ['edge', 'event', 'landing']
EDITING_TEMPLATES = [ EDITING_TEMPLATES = [
"basic-modal", "modal-button", "edit-xblock-modal", "editor-mode-button", "upload-dialog", "image-modal" "basic-modal", "modal-button", "edit-xblock-modal", "editor-mode-button", "upload-dialog", "image-modal",
"add-xblock-component", "add-xblock-component-button", "add-xblock-component-menu",
"add-xblock-component-menu-problem"
] ]
# points to the temporary course landing page with log in and sign up # points to the temporary course landing page with log in and sign up
...@@ -57,40 +59,54 @@ def get_parent_xblock(xblock): ...@@ -57,40 +59,54 @@ def get_parent_xblock(xblock):
return modulestore().get_item(parent_locations[0]) return modulestore().get_item(parent_locations[0])
def _xblock_has_studio_page(xblock): def is_unit(xblock):
"""
Returns true if the specified xblock is a vertical that is treated as a unit.
A unit is a vertical that is a direct child of a sequential (aka a subsection).
"""
if xblock.category == 'vertical':
parent_xblock = get_parent_xblock(xblock)
parent_category = parent_xblock.category if parent_xblock else None
return parent_category == 'sequential'
return False
def xblock_has_own_studio_page(xblock):
""" """
Returns true if the specified xblock has an associated Studio page. Most xblocks do 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 not have their own page but are instead shown on the page of their parent. There
are a few exceptions: are a few exceptions:
1. Courses 1. Courses
2. Verticals 2. Verticals that are either:
- themselves treated as units (in which case they are shown on a unit page)
- a direct child of a unit (in which case they are shown on a container page)
3. XBlocks with children, except for: 3. XBlocks with children, except for:
- subsections (aka sequential blocks) - sequentials (aka subsections)
- chapters - chapters (aka sections)
""" """
category = xblock.category category = xblock.category
if category in ('course', 'vertical'):
if is_unit(xblock):
return True return True
elif category == 'vertical':
parent_xblock = get_parent_xblock(xblock)
return is_unit(parent_xblock) if parent_xblock else False
elif category in ('sequential', 'chapter'): elif category in ('sequential', 'chapter'):
return False return False
elif xblock.has_children:
return True # All other xblocks with children have their own page
else: return xblock.has_children
return False
def xblock_studio_url(xblock, course=None): def xblock_studio_url(xblock, course=None):
""" """
Returns the Studio editing URL for the specified xblock. Returns the Studio editing URL for the specified xblock.
""" """
if not _xblock_has_studio_page(xblock): if not xblock_has_own_studio_page(xblock):
return None return None
category = xblock.category category = xblock.category
parent_xblock = get_parent_xblock(xblock) parent_xblock = get_parent_xblock(xblock)
if parent_xblock: parent_category = parent_xblock.category if parent_xblock else None
parent_category = parent_xblock.category
else:
parent_category = None
if category == 'course': if category == 'course':
prefix = 'course' prefix = 'course'
elif category == 'vertical' and parent_category == 'sequential': elif category == 'vertical' and parent_category == 'sequential':
......
...@@ -33,7 +33,7 @@ from util.string_utils import str_to_bool ...@@ -33,7 +33,7 @@ from util.string_utils import str_to_bool
from ..utils import get_modulestore from ..utils import get_modulestore
from .access import has_course_access from .access import has_course_access
from .helpers import _xmodule_recurse from .helpers import _xmodule_recurse, xblock_has_own_studio_page
from contentstore.utils import compute_publish_state, PublishState from contentstore.utils import compute_publish_state, PublishState
from xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES from xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES
from contentstore.views.preview import get_preview_fragment from contentstore.views.preview import get_preview_fragment
...@@ -193,46 +193,56 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v ...@@ -193,46 +193,56 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v
if 'application/json' in accept_header: if 'application/json' in accept_header:
store = get_modulestore(old_location) store = get_modulestore(old_location)
component = store.get_item(old_location) xblock = store.get_item(old_location)
is_read_only = _xblock_is_read_only(component) is_read_only = _is_xblock_read_only(xblock)
container_views = ['container_preview', 'reorderable_container_child_preview']
unit_views = ['student_view']
# wrap the generated fragment in the xmodule_editor div so that the javascript # wrap the generated fragment in the xmodule_editor div so that the javascript
# can bind to it correctly # can bind to it correctly
component.runtime.wrappers.append(partial(wrap_xblock, 'StudioRuntime')) xblock.runtime.wrappers.append(partial(wrap_xblock, 'StudioRuntime'))
if view_name == 'studio_view': if view_name == 'studio_view':
try: try:
fragment = component.render('studio_view') fragment = xblock.render('studio_view')
# catch exceptions indiscriminately, since after this point they escape the # catch exceptions indiscriminately, since after this point they escape the
# dungeon and surface as uneditable, unsaveable, and undeletable # dungeon and surface as uneditable, unsaveable, and undeletable
# component-goblins. # component-goblins.
except Exception as exc: # pylint: disable=w0703 except Exception as exc: # pylint: disable=w0703
log.debug("unable to render studio_view for %r", component, exc_info=True) log.debug("unable to render studio_view for %r", xblock, exc_info=True)
fragment = Fragment(render_to_string('html_error.html', {'message': str(exc)})) fragment = Fragment(render_to_string('html_error.html', {'message': str(exc)}))
# change not authored by requestor but by xblocks. # change not authored by requestor but by xblocks.
store.update_item(component, None) store.update_item(xblock, None)
elif view_name == 'student_view' and component.has_children: elif view_name == 'student_view' and xblock_has_own_studio_page(xblock):
context = { context = {
'runtime_type': 'studio', 'runtime_type': 'studio',
'container_view': False, 'container_view': False,
'read_only': is_read_only, 'read_only': is_read_only,
'root_xblock': component, 'root_xblock': xblock,
} }
# For non-leaf xblocks on the unit page, show the special rendering # For non-leaf xblocks on the unit page, show the special rendering
# which links to the new container page. # which links to the new container page.
html = render_to_string('container_xblock_component.html', { html = render_to_string('container_xblock_component.html', {
'xblock_context': context, 'xblock_context': context,
'xblock': component, 'xblock': xblock,
'locator': locator, 'locator': locator,
}) })
return JsonResponse({ return JsonResponse({
'html': html, 'html': html,
'resources': [], 'resources': [],
}) })
elif view_name in ('student_view', 'container_preview'): elif view_name in (unit_views + container_views):
is_container_view = (view_name == 'container_preview') is_container_view = (view_name in container_views)
# Determine the items to be shown as reorderable. Note that the view
# 'reorderable_container_child_preview' is only rendered for xblocks that
# are being shown in a reorderable container, so the xblock is automatically
# added to the list.
reorderable_items = set()
if view_name == 'reorderable_container_child_preview':
reorderable_items.add(xblock.location)
# Only show the new style HTML for the container view, i.e. for non-verticals # 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 # Note: this special case logic can be removed once the unit page is replaced
...@@ -241,10 +251,11 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v ...@@ -241,10 +251,11 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v
'runtime_type': 'studio', 'runtime_type': 'studio',
'container_view': is_container_view, 'container_view': is_container_view,
'read_only': is_read_only, 'read_only': is_read_only,
'root_xblock': component, 'root_xblock': xblock if (view_name == 'container_preview') else None,
'reorderable_items': reorderable_items
} }
fragment = get_preview_fragment(request, component, context) fragment = get_preview_fragment(request, xblock, context)
# For old-style pages (such as unit and static pages), wrap the preview with # 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 # 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. # into the preview fragment, so we don't want to add another header here.
...@@ -252,7 +263,7 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v ...@@ -252,7 +263,7 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v
fragment.content = render_to_string('component.html', { fragment.content = render_to_string('component.html', {
'xblock_context': context, 'xblock_context': context,
'preview': fragment.content, 'preview': fragment.content,
'label': component.display_name or component.scope_ids.block_type, 'label': xblock.display_name or xblock.scope_ids.block_type,
}) })
else: else:
raise Http404 raise Http404
...@@ -270,7 +281,7 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v ...@@ -270,7 +281,7 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v
return HttpResponse(status=406) return HttpResponse(status=406)
def _xblock_is_read_only(xblock): def _is_xblock_read_only(xblock):
""" """
Returns true if the specified xblock is read-only, meaning that it cannot be edited. Returns true if the specified xblock is read-only, meaning that it cannot be edited.
""" """
...@@ -411,7 +422,7 @@ def _create_item(request): ...@@ -411,7 +422,7 @@ def _create_item(request):
metadata = {} metadata = {}
data = None data = None
template_id = request.json.get('boilerplate') template_id = request.json.get('boilerplate')
if template_id is not None: if template_id:
clz = parent.runtime.load_block_type(category) clz = parent.runtime.load_block_type(category)
if clz is not None: if clz is not None:
template = clz.get_template(template_id) template = clz.get_template(template_id)
......
...@@ -28,7 +28,7 @@ from util.sandboxing import can_execute_unsafe_code ...@@ -28,7 +28,7 @@ from util.sandboxing import can_execute_unsafe_code
import static_replace import static_replace
from .session_kv_store import SessionKeyValueStore from .session_kv_store import SessionKeyValueStore
from .helpers import render_from_lms from .helpers import render_from_lms, xblock_has_own_studio_page
from ..utils import get_course_for_item from ..utils import get_course_for_item
from contentstore.views.access import get_user_role from contentstore.views.access import get_user_role
...@@ -166,6 +166,13 @@ def _load_preview_module(request, descriptor): ...@@ -166,6 +166,13 @@ def _load_preview_module(request, descriptor):
return descriptor return descriptor
def _is_xblock_reorderable(xblock, context):
"""
Returns true if the specified xblock is in the set of reorderable xblocks.
"""
return xblock.location in context['reorderable_items']
# pylint: disable=unused-argument # pylint: disable=unused-argument
def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False): def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
""" """
...@@ -173,17 +180,21 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False): ...@@ -173,17 +180,21 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
""" """
# Only add the Studio wrapper when on the container page. The unit page will remain as is for now. # 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': if context.get('container_view', None) and view == 'student_view':
root_xblock = context.get('root_xblock')
is_root = root_xblock and xblock.location == root_xblock.location
locator = loc_mapper().translate_location(xblock.course_id, xblock.location, published=False) locator = loc_mapper().translate_location(xblock.course_id, xblock.location, published=False)
is_reorderable = _is_xblock_reorderable(xblock, context)
template_context = { template_context = {
'xblock_context': context, 'xblock_context': context,
'xblock': xblock, 'xblock': xblock,
'locator': locator, 'locator': locator,
'content': frag.content, 'content': frag.content,
'is_root': is_root,
'is_reorderable': is_reorderable,
} }
if xblock.category == 'vertical': # For child xblocks with their own page, render a link to the page
template = 'studio_vertical_wrapper.html' if xblock_has_own_studio_page(xblock) and not is_root:
elif xblock.location != context.get('root_xblock').location and xblock.has_children: template = 'studio_container_wrapper.html'
template = 'container_xblock_component.html'
else: else:
template = 'studio_xblock_wrapper.html' template = 'studio_xblock_wrapper.html'
html = render_to_string(template, template_context) html = render_to_string(template, template_context)
......
""" """
Unit tests for the container view. Unit tests for the container page.
""" """
import json
from contentstore.tests.utils import CourseTestCase
from contentstore.utils import compute_publish_state, PublishState from contentstore.utils import compute_publish_state, PublishState
from contentstore.views.helpers import xblock_studio_url from contentstore.views.tests.utils import StudioPageTestCase
from xmodule.modulestore.django import loc_mapper, modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.factories import ItemFactory from xmodule.modulestore.tests.factories import ItemFactory
class ContainerViewTestCase(CourseTestCase): class ContainerPageTestCase(StudioPageTestCase):
""" """
Unit tests for the container view. Unit tests for the container page.
""" """
container_view = 'container_preview'
reorderable_child_view = 'reorderable_container_child_preview'
def setUp(self): def setUp(self):
super(ContainerViewTestCase, self).setUp() super(ContainerPageTestCase, 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, self.vertical = ItemFactory.create(parent_location=self.sequential.location,
category='vertical', display_name='Unit') category='vertical', display_name='Unit')
self.child_vertical = ItemFactory.create(parent_location=self.vertical.location, self.html = ItemFactory.create(parent_location=self.vertical.location,
category="html", display_name="HTML")
self.child_container = ItemFactory.create(parent_location=self.vertical.location,
category='split_test', display_name='Split Test')
self.child_vertical = ItemFactory.create(parent_location=self.child_container.location,
category='vertical', display_name='Child Vertical') category='vertical', display_name='Child Vertical')
self.video = ItemFactory.create(parent_location=self.child_vertical.location, self.video = ItemFactory.create(parent_location=self.child_vertical.location,
category="video", display_name="My Video") category="video", display_name="My Video")
...@@ -32,16 +32,16 @@ class ContainerViewTestCase(CourseTestCase): ...@@ -32,16 +32,16 @@ class ContainerViewTestCase(CourseTestCase):
def test_container_html(self): def test_container_html(self):
branch_name = "MITx.999.Robot_Super_Course/branch/draft/block" branch_name = "MITx.999.Robot_Super_Course/branch/draft/block"
self._test_html_content( self._test_html_content(
self.child_vertical, self.child_container,
branch_name=branch_name, branch_name=branch_name,
expected_section_tag=( expected_section_tag=(
'<section class="wrapper-xblock level-page is-hidden" ' '<section class="wrapper-xblock level-page is-hidden studio-xblock-wrapper" '
'data-locator="{branch_name}/Child_Vertical">'.format(branch_name=branch_name) 'data-locator="{branch_name}/Split_Test">'.format(branch_name=branch_name)
), ),
expected_breadcrumbs=( expected_breadcrumbs=(
r'<a href="/unit/{branch_name}/Unit"\s*' r'<a href="/unit/{branch_name}/Unit"\s*'
r'class="navigation-link navigation-parent">Unit</a>\s*' r'class="navigation-link navigation-parent">Unit</a>\s*'
r'<a href="#" class="navigation-link navigation-current">Child Vertical</a>' r'<a href="#" class="navigation-link navigation-current">Split Test</a>'
).format(branch_name=branch_name) ).format(branch_name=branch_name)
) )
...@@ -50,64 +50,53 @@ class ContainerViewTestCase(CourseTestCase): ...@@ -50,64 +50,53 @@ class ContainerViewTestCase(CourseTestCase):
Create the scenario of an xblock with children (non-vertical) on the container page. Create the scenario of an xblock with children (non-vertical) on the container page.
This should create a container page that is a child of another container page. This should create a container page that is a child of another container page.
""" """
published_xblock_with_child = ItemFactory.create( published_container = ItemFactory.create(
parent_location=self.child_vertical.location, parent_location=self.child_container.location,
category="wrapper", display_name="Wrapper" category="wrapper", display_name="Wrapper"
) )
ItemFactory.create( ItemFactory.create(
parent_location=published_xblock_with_child.location, parent_location=published_container.location,
category="html", display_name="Child HTML" category="html", display_name="Child HTML"
) )
def test_container_html(xblock):
branch_name = "MITx.999.Robot_Super_Course/branch/draft/block" branch_name = "MITx.999.Robot_Super_Course/branch/draft/block"
self._test_html_content( self._test_html_content(
published_xblock_with_child, xblock,
branch_name=branch_name, branch_name=branch_name,
expected_section_tag=( expected_section_tag=(
'<section class="wrapper-xblock level-page is-hidden" ' '<section class="wrapper-xblock level-page is-hidden studio-xblock-wrapper" '
'data-locator="{branch_name}/Wrapper">'.format(branch_name=branch_name) 'data-locator="{branch_name}/Wrapper">'.format(branch_name=branch_name)
), ),
expected_breadcrumbs=( expected_breadcrumbs=(
r'<a href="/unit/{branch_name}/Unit"\s*' r'<a href="/unit/{branch_name}/Unit"\s*'
r'class="navigation-link navigation-parent">Unit</a>\s*' r'class="navigation-link navigation-parent">Unit</a>\s*'
r'<a href="/container/{branch_name}/Child_Vertical"\s*' r'<a href="/container/{branch_name}/Split_Test"\s*'
r'class="navigation-link navigation-parent">Child Vertical</a>\s*' r'class="navigation-link navigation-parent">Split Test</a>\s*'
r'<a href="#" class="navigation-link navigation-current">Wrapper</a>' r'<a href="#" class="navigation-link navigation-current">Wrapper</a>'
).format(branch_name=branch_name) ).format(branch_name=branch_name)
) )
# Test the published version of the container
test_container_html(published_container)
# Now make the unit and its children into a draft and validate the container again # Now make the unit and its children into a draft and validate the container again
modulestore('draft').convert_to_draft(self.vertical.location) modulestore('draft').convert_to_draft(self.vertical.location)
modulestore('draft').convert_to_draft(self.child_vertical.location) modulestore('draft').convert_to_draft(self.child_vertical.location)
draft_xblock_with_child = modulestore('draft').convert_to_draft(published_xblock_with_child.location) draft_container = modulestore('draft').convert_to_draft(published_container.location)
self._test_html_content( test_container_html(draft_container)
draft_xblock_with_child,
branch_name=branch_name,
expected_section_tag=(
'<section class="wrapper-xblock level-page is-hidden" '
'data-locator="{branch_name}/Wrapper">'.format(branch_name=branch_name)
),
expected_breadcrumbs=(
r'<a href="/unit/{branch_name}/Unit"\s*'
r'class="navigation-link navigation-parent">Unit</a>\s*'
r'<a href="/container/{branch_name}/Child_Vertical"\s*'
r'class="navigation-link navigation-parent">Child Vertical</a>\s*'
r'<a href="#" class="navigation-link navigation-current">Wrapper</a>'
).format(branch_name=branch_name)
)
def _test_html_content(self, xblock, branch_name, expected_section_tag, expected_breadcrumbs): def _test_html_content(self, xblock, branch_name, expected_section_tag, expected_breadcrumbs):
""" """
Get the HTML for a container page and verify the section tag is correct Get the HTML for a container page and verify the section tag is correct
and the breadcrumbs trail is correct. and the breadcrumbs trail is correct.
""" """
url = xblock_studio_url(xblock, self.course) html = self.get_page_html(xblock)
publish_state = compute_publish_state(xblock) publish_state = compute_publish_state(xblock)
resp = self.client.get_html(url)
self.assertEqual(resp.status_code, 200)
html = resp.content
self.assertIn(expected_section_tag, html) self.assertIn(expected_section_tag, html)
# Verify the navigation link at the top of the page is correct. # Verify the navigation link at the top of the page is correct.
self.assertRegexpMatches(html, expected_breadcrumbs) self.assertRegexpMatches(html, expected_breadcrumbs)
# Verify the link that allows users to change publish status. # Verify the link that allows users to change publish status.
expected_message = None expected_message = None
if publish_state == PublishState.public: if publish_state == PublishState.public:
...@@ -119,36 +108,51 @@ class ContainerViewTestCase(CourseTestCase): ...@@ -119,36 +108,51 @@ class ContainerViewTestCase(CourseTestCase):
) )
self.assertIn(expected_unit_link, html) self.assertIn(expected_unit_link, html)
def test_container_preview_html(self): def test_public_container_preview_html(self):
""" """
Verify that an xblock returns the expected HTML for a container preview Verify that a public xblock's container preview returns the expected HTML.
""" """
# First verify that the behavior is correct with a published container self.validate_preview_html(self.vertical, self.container_view,
self._test_preview_html(self.vertical) can_edit=False, can_reorder=False, can_add=False)
self._test_preview_html(self.child_vertical) self.validate_preview_html(self.child_container, self.container_view,
can_edit=False, can_reorder=False, can_add=False)
self.validate_preview_html(self.child_vertical, self.reorderable_child_view,
can_edit=False, can_reorder=False, can_add=False)
# Now make the unit and its children into a draft and validate the preview again def test_draft_container_preview_html(self):
"""
Verify that a draft xblock's container preview returns the expected HTML.
"""
draft_unit = modulestore('draft').convert_to_draft(self.vertical.location) draft_unit = modulestore('draft').convert_to_draft(self.vertical.location)
draft_container = modulestore('draft').convert_to_draft(self.child_vertical.location) draft_child_container = modulestore('draft').convert_to_draft(self.child_container.location)
self._test_preview_html(draft_unit) draft_child_vertical = modulestore('draft').convert_to_draft(self.child_vertical.location)
self._test_preview_html(draft_container) self.validate_preview_html(draft_unit, self.container_view,
can_edit=True, can_reorder=True, can_add=True)
self.validate_preview_html(draft_child_container, self.container_view,
can_edit=True, can_reorder=True, can_add=True)
self.validate_preview_html(draft_child_vertical, self.reorderable_child_view,
can_edit=True, can_reorder=True, can_add=True)
def _test_preview_html(self, xblock): def test_public_child_container_preview_html(self):
""" """
Verify that the specified xblock has the expected HTML elements for container preview Verify that a public container rendered as a child of the container page returns the expected HTML.
""" """
locator = loc_mapper().translate_location(self.course.id, xblock.location, published=False) empty_child_container = ItemFactory.create(parent_location=self.vertical.location,
publish_state = compute_publish_state(xblock) category='split_test', display_name='Split Test')
preview_url = '/xblock/{locator}/container_preview'.format(locator=locator) ItemFactory.create(parent_location=empty_child_container.location,
category='html', display_name='Split Child')
self.validate_preview_html(empty_child_container, self.reorderable_child_view,
can_reorder=False, can_edit=False, can_add=False)
resp = self.client.get(preview_url, HTTP_ACCEPT='application/json') def test_draft_child_container_preview_html(self):
self.assertEqual(resp.status_code, 200) """
resp_content = json.loads(resp.content) Verify that a draft container rendered as a child of the container page returns the expected HTML.
html = resp_content['html'] """
empty_child_container = ItemFactory.create(parent_location=self.vertical.location,
# Verify that there are no drag handles for public pages category='split_test', display_name='Split Test')
drag_handle_html = '<span data-tooltip="Drag to reorder" class="drag-handle action"></span>' ItemFactory.create(parent_location=empty_child_container.location,
if publish_state == PublishState.public: category='html', display_name='Split Child')
self.assertNotIn(drag_handle_html, html) modulestore('draft').convert_to_draft(self.vertical.location)
else: draft_empty_child_container = modulestore('draft').convert_to_draft(empty_child_container.location)
self.assertIn(drag_handle_html, html) self.validate_preview_html(draft_empty_child_container, self.reorderable_child_view,
can_reorder=True, can_edit=False, can_add=False)
...@@ -189,7 +189,7 @@ class TabsPageTests(CourseTestCase): ...@@ -189,7 +189,7 @@ class TabsPageTests(CourseTestCase):
self.assertIn('<span class="action-button-text">Edit</span>', html) self.assertIn('<span class="action-button-text">Edit</span>', html)
self.assertIn('<span class="sr">Duplicate this component</span>', html) self.assertIn('<span class="sr">Duplicate this component</span>', html)
self.assertIn('<span class="sr">Delete this component</span>', html) self.assertIn('<span class="sr">Delete this component</span>', html)
self.assertIn('<span data-tooltip="Drag to reorder" class="drag-handle"></span>', html) self.assertIn('<span data-tooltip="Drag to reorder" class="drag-handle action"></span>', html)
......
"""
Unit tests for the unit page.
"""
from contentstore.views.tests.utils import StudioPageTestCase
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.factories import ItemFactory
class UnitPageTestCase(StudioPageTestCase):
"""
Unit tests for the unit page.
"""
def setUp(self):
super(UnitPageTestCase, self).setUp()
self.vertical = ItemFactory.create(parent_location=self.sequential.location,
category='vertical', display_name='Unit')
self.video = ItemFactory.create(parent_location=self.vertical.location,
category="video", display_name="My Video")
def test_public_unit_page_html(self):
"""
Verify that an xblock returns the expected HTML for a public unit page.
"""
html = self.get_page_html(self.vertical)
self.validate_html_for_add_buttons(html)
def test_draft_unit_page_html(self):
"""
Verify that an xblock returns the expected HTML for a draft unit page.
"""
draft_unit = modulestore('draft').convert_to_draft(self.vertical.location)
html = self.get_page_html(draft_unit)
self.validate_html_for_add_buttons(html)
def test_public_component_preview_html(self):
"""
Verify that a public xblock's preview returns the expected HTML.
"""
self.validate_preview_html(self.video, 'student_view',
can_edit=True, can_reorder=True, can_add=False)
def test_draft_component_preview_html(self):
"""
Verify that a draft xblock's preview returns the expected HTML.
"""
modulestore('draft').convert_to_draft(self.vertical.location)
draft_video = modulestore('draft').convert_to_draft(self.video.location)
self.validate_preview_html(draft_video, 'student_view',
can_edit=True, can_reorder=True, can_add=False)
def test_public_child_container_preview_html(self):
"""
Verify that a public child container rendering on the unit page (which shows a View arrow
to the container page) returns the expected HTML.
"""
child_container = ItemFactory.create(parent_location=self.vertical.location,
category='split_test', display_name='Split Test')
ItemFactory.create(parent_location=child_container.location,
category='html', display_name='grandchild')
self.validate_preview_html(child_container, 'student_view',
can_reorder=True, can_edit=False, can_add=False)
def test_draft_child_container_preview_html(self):
"""
Verify that a draft child container rendering on the unit page (which shows a View arrow
to the container page) returns the expected HTML.
"""
child_container = ItemFactory.create(parent_location=self.vertical.location,
category='split_test', display_name='Split Test')
ItemFactory.create(parent_location=child_container.location,
category='html', display_name='grandchild')
modulestore('draft').convert_to_draft(self.vertical.location)
draft_child_container = modulestore('draft').get_item(child_container.location)
self.validate_preview_html(draft_child_container, 'student_view',
can_reorder=True, can_edit=False, can_add=False)
"""
Utilities for view tests.
"""
import json
from contentstore.tests.utils import CourseTestCase
from contentstore.views.helpers import xblock_studio_url
from xmodule.modulestore.django import loc_mapper
from xmodule.modulestore.tests.factories import ItemFactory
class StudioPageTestCase(CourseTestCase):
"""
Base class for all tests of Studio pages.
"""
def setUp(self):
super(StudioPageTestCase, 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")
def get_page_html(self, xblock):
"""
Returns the HTML for the page representing the xblock.
"""
url = xblock_studio_url(xblock, self.course)
self.assertIsNotNone(url)
resp = self.client.get_html(url)
self.assertEqual(resp.status_code, 200)
return resp.content
def get_preview_html(self, xblock, view_name):
"""
Returns the HTML for the xblock when shown within a unit or container page.
"""
locator = loc_mapper().translate_location(self.course.id, xblock.location, published=False)
preview_url = '/xblock/{locator}/{view_name}'.format(locator=locator, view_name=view_name)
resp = self.client.get_json(preview_url)
self.assertEqual(resp.status_code, 200)
resp_content = json.loads(resp.content)
return resp_content['html']
def validate_preview_html(self, xblock, view_name, can_edit=True, can_reorder=True, can_add=True):
"""
Verify that the specified xblock's preview has the expected HTML elements.
"""
html = self.get_preview_html(xblock, view_name)
self.validate_html_for_add_buttons(html, can_add=can_add)
# Verify that there are no drag handles for public blocks
drag_handle_html = '<span data-tooltip="Drag to reorder" class="drag-handle action"></span>'
if can_reorder:
self.assertIn(drag_handle_html, html)
else:
self.assertNotIn(drag_handle_html, html)
# Verify that there are no action buttons for public blocks
expected_button_html = [
'<a href="#" class="edit-button action-button">',
'<a href="#" data-tooltip="Delete" class="delete-button action-button">',
'<a href="#" data-tooltip="Duplicate" class="duplicate-button action-button">'
]
for button_html in expected_button_html:
if can_edit:
self.assertIn(button_html, html)
else:
self.assertNotIn(button_html, html)
def validate_html_for_add_buttons(self, html, can_add=True):
"""
Validate that the specified HTML has the appropriate add actions for the current publish state.
"""
# Verify that there are no add buttons for public blocks
add_button_html = '<div class="add-xblock-component new-component-item adding"></div>'
if can_add:
self.assertIn(add_button_html, html)
else:
self.assertNotIn(add_button_html, html)
...@@ -102,12 +102,6 @@ FEATURES = { ...@@ -102,12 +102,6 @@ FEATURES = {
# Turn off Advanced Security by default # Turn off Advanced Security by default
'ADVANCED_SECURITY': False, 'ADVANCED_SECURITY': False,
# Temporary feature flag for duplicating xblock leaves
'ENABLE_DUPLICATE_XBLOCK_LEAF_COMPONENT': False,
# Temporary feature flag for deleting xblock leaves
'ENABLE_DELETE_XBLOCK_LEAF_COMPONENT': False,
} }
ENABLE_JASMINE = False ENABLE_JASMINE = False
......
...@@ -19,7 +19,7 @@ define ["jquery", "js/spec_helpers/edit_helpers", "coffee/src/views/module_edit" ...@@ -19,7 +19,7 @@ define ["jquery", "js/spec_helpers/edit_helpers", "coffee/src/views/module_edit"
<a href="#" class="edit-button"><span class="edit-icon white"></span>Edit</a> <a href="#" class="edit-button"><span class="edit-icon white"></span>Edit</a>
<a href="#" class="delete-button"><span class="delete-icon white"></span>Delete</a> <a href="#" class="delete-button"><span class="delete-icon white"></span>Delete</a>
</div> </div>
<span class="drag-handle"></span> <span class="drag-handle action"></span>
<section class="xblock xblock-student_view xmodule_display xmodule_stub" data-type="StubModule"> <section class="xblock xblock-student_view xmodule_display xmodule_stub" data-type="StubModule">
<div id="stub-module-content"/> <div id="stub-module-content"/>
</section> </section>
......
define ["jquery", "jquery.ui", "gettext", "backbone", define ["jquery", "jquery.ui", "gettext", "backbone",
"js/views/feedback_notification", "js/views/feedback_prompt", "js/views/feedback_notification", "js/views/feedback_prompt",
"coffee/src/views/module_edit", "js/models/module_info", "coffee/src/views/module_edit", "js/models/module_info",
"js/views/baseview"], "js/views/baseview", "js/views/components/add_xblock"],
($, ui, gettext, Backbone, NotificationView, PromptView, ModuleEditView, ModuleModel, BaseView) -> ($, ui, gettext, Backbone, NotificationView, PromptView, ModuleEditView, ModuleModel, BaseView, AddXBlockComponent) ->
class UnitEditView extends BaseView class UnitEditView extends BaseView
events: events:
'click .new-component .new-component-type a.multiple-templates': 'showComponentTemplates'
'click .new-component .new-component-type a.single-template': 'saveNewComponent'
'click .new-component .cancel-button': 'closeNewComponent'
'click .new-component-templates .new-component-template a': 'saveNewComponent'
'click .new-component-templates .cancel-button': 'closeNewComponent'
'click .delete-draft': 'deleteDraft' 'click .delete-draft': 'deleteDraft'
'click .create-draft': 'createDraft' 'click .create-draft': 'createDraft'
'click .publish-draft': 'publishDraft' 'click .publish-draft': 'publishDraft'
...@@ -32,12 +27,20 @@ define ["jquery", "jquery.ui", "gettext", "backbone", ...@@ -32,12 +27,20 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
model: @model model: @model
) )
@addXBlockComponent = new AddXBlockComponent(
collection: @options.templates
el: @$('.add-xblock-component')
createComponent: (template) =>
return @createComponent(template, "Creating new component").done(
(editor) ->
listPanel = @$newComponentItem.prev()
listPanel.append(editor.$el)
))
@addXBlockComponent.render()
@model.on('change:state', @render) @model.on('change:state', @render)
@$newComponentItem = @$('.new-component-item') @$newComponentItem = @$('.new-component-item')
@$newComponentTypePicker = @$('.new-component')
@$newComponentTemplatePickers = @$('.new-component-templates')
@$newComponentButton = @$('.new-component-button')
@$('.components').sortable( @$('.components').sortable(
handle: '.drag-handle' handle: '.drag-handle'
...@@ -70,40 +73,16 @@ define ["jquery", "jquery.ui", "gettext", "backbone", ...@@ -70,40 +73,16 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
onDelete: @deleteComponent, onDelete: @deleteComponent,
model: model model: model
showComponentTemplates: (event) => createComponent: (data, analytics_message) =>
event.preventDefault() self = this
operation = $.Deferred()
type = $(event.currentTarget).data('type')
@$newComponentTypePicker.slideUp(250)
@$(".new-component-#{type}").slideDown(250)
$('html, body').animate({
scrollTop: @$(".new-component-#{type}").offset().top
}, 500)
closeNewComponent: (event) =>
event.preventDefault()
@$newComponentTypePicker.slideDown(250)
@$newComponentTemplatePickers.slideUp(250)
@$newComponentItem.removeClass('adding')
@$newComponentItem.find('.rendered-component').remove()
createComponent: (event, data, notification_message, analytics_message, success_callback) =>
event.preventDefault()
editor = new ModuleEditView( editor = new ModuleEditView(
onDelete: @deleteComponent onDelete: @deleteComponent
model: new ModuleModel() model: new ModuleModel()
) )
notification = new NotificationView.Mini
title: notification_message
notification.show()
callback = -> callback = ->
notification.hide() operation.resolveWith(self, [editor])
success_callback()
analytics.track analytics_message, analytics.track analytics_message,
course: course_location_analytics course: course_location_analytics
unit_id: unit_location_analytics unit_id: unit_location_analytics
...@@ -115,34 +94,24 @@ define ["jquery", "jquery.ui", "gettext", "backbone", ...@@ -115,34 +94,24 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
callback callback
) )
return editor return operation.promise()
saveNewComponent: (event) =>
success_callback = =>
@$newComponentItem.before(editor.$el)
editor = @createComponent(
event, $(event.currentTarget).data(),
gettext('Adding&hellip;'),
"Creating new component",
success_callback
)
@closeNewComponent(event)
duplicateComponent: (event) => duplicateComponent: (event) =>
self = this
event.preventDefault()
$component = $(event.currentTarget).parents('.component') $component = $(event.currentTarget).parents('.component')
source_locator = $component.data('locator') source_locator = $component.data('locator')
success_callback = -> @runOperationShowingMessage(gettext('Duplicating&hellip;'), ->
$component.after(editor.$el) operation = self.createComponent(
$('html, body').animate({
scrollTop: editor.$el.offset().top
}, 500)
editor = @createComponent(
event,
{duplicate_source_locator: source_locator}, {duplicate_source_locator: source_locator},
gettext('Duplicating&hellip;') "Duplicating " + source_locator);
"Duplicating " + source_locator, operation.done(
success_callback (editor) ->
) originalOffset = @getScrollOffset($component)
$component.after(editor.$el)
# Scroll the window so that the new component replaces the old one
@setScrollOffset(editor.$el, originalOffset)
))
components: => @$('.component').map((idx, el) -> $(el).data('locator')).get() components: => @$('.component').map((idx, el) -> $(el).data('locator')).get()
...@@ -158,24 +127,19 @@ define ["jquery", "jquery.ui", "gettext", "backbone", ...@@ -158,24 +127,19 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
@model.save() @model.save()
deleteComponent: (event) => deleteComponent: (event) =>
self = this
event.preventDefault() event.preventDefault()
msg = new PromptView.Warning( @confirmThenRunOperation(gettext('Delete this component?'),
title: gettext('Delete this component?'), gettext('Deleting this component is permanent and cannot be undone.'),
message: gettext('Deleting this component is permanent and cannot be undone.'), gettext('Yes, delete this component'),
actions: ->
primary: self.runOperationShowingMessage(gettext('Deleting&hellip;'),
text: gettext('Yes, delete this component'), ->
click: (view) =>
view.hide()
deleting = new NotificationView.Mini
title: gettext('Deleting&hellip;'),
deleting.show()
$component = $(event.currentTarget).parents('.component') $component = $(event.currentTarget).parents('.component')
$.ajax({ return $.ajax({
type: 'DELETE', type: 'DELETE',
url: @model.urlRoot + "/" + $component.data('locator') url: self.model.urlRoot + "/" + $component.data('locator')
}).success(=> }).success(=>
deleting.hide()
analytics.track "Deleted a Component", analytics.track "Deleted a Component",
course: course_location_analytics course: course_location_analytics
unit_id: unit_location_analytics unit_id: unit_location_analytics
...@@ -184,19 +148,12 @@ define ["jquery", "jquery.ui", "gettext", "backbone", ...@@ -184,19 +148,12 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
$component.remove() $component.remove()
# b/c we don't vigilantly keep children up to date # b/c we don't vigilantly keep children up to date
# get rid of it before it hurts someone # get rid of it before it hurts someone
# sorry for the js, i couldn't figure out the coffee equivalent self.model.save({children: self.components()},
`_this.model.save({children: _this.components()}, {
{success: function(model) { success: (model) ->
model.unset('children'); model.unset('children')
}} })
);` )))
)
secondary:
text: gettext('Cancel'),
click: (view) ->
view.hide()
)
msg.show()
deleteDraft: (event) -> deleteDraft: (event) ->
@wait(true) @wait(true)
...@@ -262,8 +219,7 @@ define ["jquery", "jquery.ui", "gettext", "backbone", ...@@ -262,8 +219,7 @@ define ["jquery", "jquery.ui", "gettext", "backbone",
unit_id: unit_location_analytics unit_id: unit_location_analytics
visibility: visibility visibility: visibility
@model.set('state', @$('.visibility-select').val()) @model.set('state', @$('.visibility-select').val()))
)
class UnitEditView.NameEdit extends BaseView class UnitEditView.NameEdit extends BaseView
events: events:
......
define(["backbone", "js/models/component_template"], function(Backbone, ComponentTemplate) {
return Backbone.Collection.extend({
model : ComponentTemplate
});
});
/**
* Simple model for adding a component of a given type (for example, "video" or "html").
*/
define(["backbone"], function (Backbone) {
return Backbone.Model.extend({
defaults: {
type: "",
// Each entry in the template array is an Object with the following keys:
// display_name
// category (may or may not match "type")
// boilerplate_name (may be null)
// is_common (only used for problems)
templates: []
},
parse: function (response) {
this.type = response.type;
this.templates = response.templates;
// Sort the templates.
this.templates.sort(function (a, b) {
// The entry without a boilerplate always goes first
if (!a.boilerplate_name || (a.display_name < b.display_name)) {
return -1;
}
else {
return (a.display_name > b.display_name) ? 1 : 0;
}
});
}
});
});
define(["jquery", "underscore", "js/views/baseview", "js/utils/handle_iframe_binding", "sinon"], define(["jquery", "underscore", "js/views/baseview", "js/utils/handle_iframe_binding", "sinon",
function ($, _, BaseView, IframeBinding, sinon) { "js/spec_helpers/edit_helpers"],
function ($, _, BaseView, IframeBinding, sinon, view_helpers) {
describe("BaseView", function() { describe("BaseView", function() {
var baseViewPrototype; var baseViewPrototype;
...@@ -79,8 +80,7 @@ define(["jquery", "underscore", "js/views/baseview", "js/utils/handle_iframe_bin ...@@ -79,8 +80,7 @@ define(["jquery", "underscore", "js/views/baseview", "js/utils/handle_iframe_bin
describe("disabled element while running", function() { describe("disabled element while running", function() {
it("adds 'is-disabled' class to element while action is running and removes it after", function() { it("adds 'is-disabled' class to element while action is running and removes it after", function() {
var viewWithLink, var link,
link,
deferred = new $.Deferred(), deferred = new $.Deferred(),
promise = deferred.promise(), promise = deferred.promise(),
view = new BaseView(); view = new BaseView();
...@@ -89,11 +89,37 @@ define(["jquery", "underscore", "js/views/baseview", "js/utils/handle_iframe_bin ...@@ -89,11 +89,37 @@ define(["jquery", "underscore", "js/views/baseview", "js/utils/handle_iframe_bin
link = $("#link"); link = $("#link");
expect(link).not.toHaveClass("is-disabled"); expect(link).not.toHaveClass("is-disabled");
view.disableElementWhileRunning(link, function(){return promise}); view.disableElementWhileRunning(link, function() { return promise; });
expect(link).toHaveClass("is-disabled"); expect(link).toHaveClass("is-disabled");
deferred.resolve(); deferred.resolve();
expect(link).not.toHaveClass("is-disabled"); expect(link).not.toHaveClass("is-disabled");
}); });
}); });
describe("progress notification", function() {
it("shows progress notification and removes it upon success", function() {
var testMessage = "Testing...",
deferred = new $.Deferred(),
promise = deferred.promise(),
view = new BaseView(),
notificationSpy = view_helpers.createNotificationSpy();
view.runOperationShowingMessage(testMessage, function() { return promise; });
view_helpers.verifyNotificationShowing(notificationSpy, /Testing/);
deferred.resolve();
view_helpers.verifyNotificationHidden(notificationSpy);
});
it("shows progress notification and leaves it showing upon failure", function() {
var testMessage = "Testing...",
deferred = new $.Deferred(),
promise = deferred.promise(),
view = new BaseView(),
notificationSpy = view_helpers.createNotificationSpy();
view.runOperationShowingMessage(testMessage, function() { return promise; });
view_helpers.verifyNotificationShowing(notificationSpy, /Testing/);
deferred.fail();
view_helpers.verifyNotificationShowing(notificationSpy, /Testing/);
});
});
}); });
}); });
define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers", define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers",
"js/views/container", "js/models/xblock_info", "js/views/feedback_notification", "jquery.simulate", "js/views/container", "js/models/xblock_info", "jquery.simulate",
"xmodule", "coffee/src/main", "xblock/cms.runtime.v1"], "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
function ($, create_sinon, view_helpers, ContainerView, XBlockInfo, Notification) { function ($, create_sinon, view_helpers, ContainerView, XBlockInfo) {
describe("Container View", function () { describe("Container View", function () {
...@@ -9,7 +9,7 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers ...@@ -9,7 +9,7 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers
var model, containerView, mockContainerHTML, respondWithMockXBlockFragment, init, getComponent, var model, containerView, mockContainerHTML, respondWithMockXBlockFragment, init, getComponent,
getDragHandle, dragComponentVertically, dragComponentAbove, getDragHandle, dragComponentVertically, dragComponentAbove,
verifyRequest, verifyNumReorderCalls, respondToRequest, verifyRequest, verifyNumReorderCalls, respondToRequest, notificationSpy,
rootLocator = 'testCourse/branch/draft/split_test/splitFFF', rootLocator = 'testCourse/branch/draft/split_test/splitFFF',
containerTestUrl = '/xblock/' + rootLocator, containerTestUrl = '/xblock/' + rootLocator,
...@@ -35,7 +35,8 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers ...@@ -35,7 +35,8 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers
beforeEach(function () { beforeEach(function () {
view_helpers.installViewTemplates(); view_helpers.installViewTemplates();
appendSetFixtures('<div class="wrapper-xblock level-page" data-locator="' + rootLocator + '"></div>'); appendSetFixtures('<div class="wrapper-xblock level-page studio-xblock-wrapper" data-locator="' + rootLocator + '"></div>');
notificationSpy = view_helpers.createNotificationSpy();
model = new XBlockInfo({ model = new XBlockInfo({
id: rootLocator, id: rootLocator,
display_name: 'Test AB Test', display_name: 'Test AB Test',
...@@ -63,16 +64,29 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers ...@@ -63,16 +64,29 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers
}); });
$('body').append(containerView.$el); $('body').append(containerView.$el);
// Give the whole container enough height to contain everything.
$('.xblock[data-locator=locator-container]').css('height', 2000);
// Give the groups enough height to contain their child vertical elements.
$('.is-draggable[data-locator=locator-group-A]').css('height', 800);
$('.is-draggable[data-locator=locator-group-B]').css('height', 800);
// Give the leaf elements some height to mimic actual components. Otherwise
// drag and drop fails as the elements on bunched on top of each other.
$('.level-element').css('height', 200);
return requests; return requests;
}; };
getComponent = function(locator) { getComponent = function(locator) {
return containerView.$('[data-locator="' + locator + '"]'); return containerView.$('.studio-xblock-wrapper[data-locator="' + locator + '"]');
}; };
getDragHandle = function(locator) { getDragHandle = function(locator) {
var component = getComponent(locator); var component = getComponent(locator);
return component.prev(); return $(component.find('.drag-handle')[0]);
}; };
dragComponentVertically = function (locator, dy) { dragComponentVertically = function (locator, dy) {
...@@ -166,31 +180,17 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers ...@@ -166,31 +180,17 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers
}); });
describe("Shows a saving message", function () { describe("Shows a saving message", function () {
var savingSpies;
beforeEach(function () {
savingSpies = spyOnConstructor(Notification, "Mini",
["show", "hide"]);
savingSpies.show.andReturn(savingSpies);
});
it('hides saving message upon success', function () { it('hides saving message upon success', function () {
var requests, savingOptions; var requests, savingOptions;
requests = init(this); requests = init(this);
// Drag the first component in Group B to the first group. // Drag the first component in Group B to the first group.
dragComponentAbove(groupBComponent1, groupAComponent1); dragComponentAbove(groupBComponent1, groupAComponent1);
view_helpers.verifyNotificationShowing(notificationSpy, 'Saving');
expect(savingSpies.constructor).toHaveBeenCalled();
expect(savingSpies.show).toHaveBeenCalled();
expect(savingSpies.hide).not.toHaveBeenCalled();
savingOptions = savingSpies.constructor.mostRecentCall.args[0];
expect(savingOptions.title).toMatch(/Saving/);
respondToRequest(requests, 0, 200); respondToRequest(requests, 0, 200);
expect(savingSpies.hide).not.toHaveBeenCalled(); view_helpers.verifyNotificationShowing(notificationSpy, 'Saving');
respondToRequest(requests, 1, 200); respondToRequest(requests, 1, 200);
expect(savingSpies.hide).toHaveBeenCalled(); view_helpers.verifyNotificationHidden(notificationSpy);
}); });
it('does not hide saving message if failure', function () { it('does not hide saving message if failure', function () {
...@@ -198,13 +198,9 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers ...@@ -198,13 +198,9 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers
// Drag the first component in Group B to the first group. // Drag the first component in Group B to the first group.
dragComponentAbove(groupBComponent1, groupAComponent1); dragComponentAbove(groupBComponent1, groupAComponent1);
view_helpers.verifyNotificationShowing(notificationSpy, 'Saving');
expect(savingSpies.constructor).toHaveBeenCalled();
expect(savingSpies.show).toHaveBeenCalled();
expect(savingSpies.hide).not.toHaveBeenCalled();
respondToRequest(requests, 0, 500); respondToRequest(requests, 0, 500);
expect(savingSpies.hide).not.toHaveBeenCalled(); view_helpers.verifyNotificationShowing(notificationSpy, 'Saving');
// Since the first reorder call failed, the removal will not be called. // Since the first reorder call failed, the removal will not be called.
verifyNumReorderCalls(requests, 1); verifyNumReorderCalls(requests, 1);
......
define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers", define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers",
"js/views/feedback_notification", "js/views/feedback_prompt", "js/views/feedback_prompt", "js/views/pages/container", "js/models/xblock_info"],
"js/views/pages/container", "js/models/xblock_info"], function ($, _, create_sinon, edit_helpers, Prompt, ContainerPage, XBlockInfo) {
function ($, create_sinon, edit_helpers, Notification, Prompt, ContainerPage, XBlockInfo) {
describe("ContainerPage", function() { describe("ContainerPage", function() {
var lastRequest, renderContainerPage, expectComponents, respondWithHtml, var lastRequest, renderContainerPage, expectComponents, respondWithHtml,
model, containerPage, requests, model, containerPage, requests,
mockContainerPage = readFixtures('mock/mock-container-page.underscore'), mockContainerPage = readFixtures('mock/mock-container-page.underscore'),
ABTestFixture = readFixtures('mock/mock-container-xblock.underscore'); mockContainerXBlockHtml = readFixtures('mock/mock-container-xblock.underscore'),
mockXBlockEditorHtml = readFixtures('mock/mock-xblock-editor.underscore');
beforeEach(function () { beforeEach(function () {
edit_helpers.installEditTemplates(); edit_helpers.installEditTemplates();
...@@ -20,6 +20,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers" ...@@ -20,6 +20,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
}); });
containerPage = new ContainerPage({ containerPage = new ContainerPage({
model: model, model: model,
templates: edit_helpers.mockComponentTemplates,
el: $('#content') el: $('#content')
}); });
}); });
...@@ -43,7 +44,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers" ...@@ -43,7 +44,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
expectComponents = function (container, locators) { expectComponents = function (container, locators) {
// verify expected components (in expected order) by their locators // verify expected components (in expected order) by their locators
var components = $(container).find('[data-locator]'); var components = $(container).find('.studio-xblock-wrapper');
expect(components.length).toBe(locators.length); expect(components.length).toBe(locators.length);
_.each(locators, function(locator, locator_index) { _.each(locators, function(locator, locator_index) {
expect($(components[locator_index]).data('locator')).toBe(locator); expect($(components[locator_index]).data('locator')).toBe(locator);
...@@ -51,8 +52,6 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers" ...@@ -51,8 +52,6 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
}; };
describe("Basic display", function() { describe("Basic display", function() {
var mockContainerXBlockHtml = readFixtures('mock/mock-container-xblock.underscore');
it('can render itself', function() { it('can render itself', function() {
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(mockContainerXBlockHtml, this);
expect(containerPage.$el.select('.xblock-header')).toBeTruthy(); expect(containerPage.$el.select('.xblock-header')).toBeTruthy();
...@@ -69,9 +68,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers" ...@@ -69,9 +68,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
}); });
describe("Editing an xblock", function() { describe("Editing an xblock", function() {
var mockContainerXBlockHtml, var newDisplayName = 'New Display Name';
mockXBlockEditorHtml,
newDisplayName = 'New Display Name';
beforeEach(function () { beforeEach(function () {
edit_helpers.installMockXBlock({ edit_helpers.installMockXBlock({
...@@ -87,9 +84,6 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers" ...@@ -87,9 +84,6 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
edit_helpers.cancelModalIfShowing(); edit_helpers.cancelModalIfShowing();
}); });
mockContainerXBlockHtml = readFixtures('mock/mock-container-xblock.underscore');
mockXBlockEditorHtml = readFixtures('mock/mock-xblock-editor.underscore');
it('can show an edit modal for a child xblock', function() { it('can show an edit modal for a child xblock', function() {
var editButtons; var editButtons;
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(mockContainerXBlockHtml, this);
...@@ -110,8 +104,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers" ...@@ -110,8 +104,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
}); });
describe("Editing an xmodule", function() { describe("Editing an xmodule", function() {
var mockContainerXBlockHtml, var mockXModuleEditor = readFixtures('mock/mock-xmodule-editor.underscore'),
mockXModuleEditor,
newDisplayName = 'New Display Name'; newDisplayName = 'New Display Name';
beforeEach(function () { beforeEach(function () {
...@@ -128,9 +121,6 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers" ...@@ -128,9 +121,6 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
edit_helpers.cancelModalIfShowing(); edit_helpers.cancelModalIfShowing();
}); });
mockContainerXBlockHtml = readFixtures('mock/mock-container-xblock.underscore');
mockXModuleEditor = readFixtures('mock/mock-xmodule-editor.underscore');
it('can save changes to settings', function() { it('can save changes to settings', function() {
var editButtons, modal, mockUpdatedXBlockHtml; var editButtons, modal, mockUpdatedXBlockHtml;
mockUpdatedXBlockHtml = readFixtures('mock/mock-updated-xblock.underscore'); mockUpdatedXBlockHtml = readFixtures('mock/mock-updated-xblock.underscore');
...@@ -165,43 +155,32 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers" ...@@ -165,43 +155,32 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
}); });
describe("Empty container", function() { describe("Empty container", function() {
var mockContainerXBlockHtml = readFixtures('mock/mock-empty-container-xblock.underscore'); var mockEmptyContainerXBlockHtml = readFixtures('mock/mock-empty-container-xblock.underscore');
it('shows the "no children" message', function() { it('shows the "no children" message', function() {
renderContainerPage(mockContainerXBlockHtml, this); renderContainerPage(mockEmptyContainerXBlockHtml, this);
expect(containerPage.$('.no-container-content')).not.toHaveClass('is-hidden'); expect(containerPage.$('.no-container-content')).not.toHaveClass('is-hidden');
expect(containerPage.$('.wrapper-xblock')).toHaveClass('is-hidden'); expect(containerPage.$('.wrapper-xblock')).toHaveClass('is-hidden');
}); });
}); });
describe("xblock operations", function() { describe("xblock operations", function() {
var getGroupElement, expectNumComponents, expectNotificationToBeShown, var getGroupElement, expectNumComponents,
NUM_GROUPS = 2, NUM_COMPONENTS_PER_GROUP = 3, GROUP_TO_TEST = "A", NUM_GROUPS = 2, NUM_COMPONENTS_PER_GROUP = 3, GROUP_TO_TEST = "A",
notificationSpies,
allComponentsInGroup = _.map( allComponentsInGroup = _.map(
_.range(NUM_COMPONENTS_PER_GROUP), _.range(NUM_COMPONENTS_PER_GROUP),
function(index) { return 'locator-component-' + GROUP_TO_TEST + (index + 1); } function(index) { return 'locator-component-' + GROUP_TO_TEST + (index + 1); }
); );
beforeEach(function () {
notificationSpies = spyOnConstructor(Notification, "Mini", ["show", "hide"]);
notificationSpies.show.andReturn(notificationSpies);
});
getGroupElement = function() { getGroupElement = function() {
return containerPage.$("[data-locator='locator-group-" + GROUP_TO_TEST + "']"); return containerPage.$("[data-locator='locator-group-" + GROUP_TO_TEST + "']");
}; };
expectNumComponents = function(numComponents) { expectNumComponents = function(numComponents) {
expect(containerPage.$('.wrapper-xblock.level-element').length).toBe( expect(containerPage.$('.wrapper-xblock.level-element').length).toBe(
numComponents * NUM_GROUPS numComponents * NUM_GROUPS
); );
}; };
expectNotificationToBeShown = function(expectedTitle) {
expect(notificationSpies.constructor).toHaveBeenCalled();
expect(notificationSpies.show).toHaveBeenCalled();
expect(notificationSpies.hide).not.toHaveBeenCalled();
expect(notificationSpies.constructor.mostRecentCall.args[0].title).toMatch(expectedTitle);
};
describe("Deleting an xblock", function() { describe("Deleting an xblock", function() {
var clickDelete, deleteComponent, deleteComponentWithSuccess, var clickDelete, deleteComponent, deleteComponentWithSuccess,
...@@ -212,7 +191,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers" ...@@ -212,7 +191,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
promptSpies.show.andReturn(this.promptSpies); promptSpies.show.andReturn(this.promptSpies);
}); });
clickDelete = function(componentIndex) { clickDelete = function(componentIndex, clickNo) {
// find all delete buttons for the given group // find all delete buttons for the given group
var deleteButtons = getGroupElement().find(".delete-button"); var deleteButtons = getGroupElement().find(".delete-button");
...@@ -226,21 +205,18 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers" ...@@ -226,21 +205,18 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
// no components should be deleted yet // no components should be deleted yet
expectNumComponents(NUM_COMPONENTS_PER_GROUP); expectNumComponents(NUM_COMPONENTS_PER_GROUP);
};
deleteComponent = function(componentIndex, responseCode) {
// click delete button for given component
clickDelete(componentIndex);
// click 'Yes' on delete confirmation // click 'Yes' or 'No' on delete confirmation
if (clickNo) {
promptSpies.constructor.mostRecentCall.args[0].actions.secondary.click(promptSpies);
} else {
promptSpies.constructor.mostRecentCall.args[0].actions.primary.click(promptSpies); promptSpies.constructor.mostRecentCall.args[0].actions.primary.click(promptSpies);
}
};
// expect 'deleting' notification to be shown deleteComponent = function(componentIndex) {
expectNotificationToBeShown(/Deleting/); clickDelete(componentIndex);
create_sinon.respondWithJson(requests, {});
// respond to request with given response code
lastRequest().respond(responseCode, {}, "");
// expect request URL to contain given component's id // expect request URL to contain given component's id
expect(lastRequest().url).toMatch( expect(lastRequest().url).toMatch(
...@@ -249,12 +225,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers" ...@@ -249,12 +225,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
}; };
deleteComponentWithSuccess = function(componentIndex) { deleteComponentWithSuccess = function(componentIndex) {
deleteComponent(componentIndex);
// delete component with an 'OK' response code
deleteComponent(componentIndex, 200);
// expect 'deleting' notification to be hidden
expect(notificationSpies.hide).toHaveBeenCalled();
// verify the new list of components within the group // verify the new list of components within the group
expectComponents( expectComponents(
...@@ -263,32 +234,29 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers" ...@@ -263,32 +234,29 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
); );
}; };
it("deletes first xblock", function() { it("can delete the first xblock", function() {
renderContainerPage(ABTestFixture, this); renderContainerPage(mockContainerXBlockHtml, this);
deleteComponentWithSuccess(0); deleteComponentWithSuccess(0);
}); });
it("deletes middle xblock", function() { it("can delete a middle xblock", function() {
renderContainerPage(ABTestFixture, this); renderContainerPage(mockContainerXBlockHtml, this);
deleteComponentWithSuccess(1); deleteComponentWithSuccess(1);
}); });
it("deletes last xblock", function() { it("can delete the last xblock", function() {
renderContainerPage(ABTestFixture, this); renderContainerPage(mockContainerXBlockHtml, this);
deleteComponentWithSuccess(NUM_COMPONENTS_PER_GROUP - 1); deleteComponentWithSuccess(NUM_COMPONENTS_PER_GROUP - 1);
}); });
it('does not delete xblock when clicking No in prompt', function () { it('does not delete when clicking No in prompt', function () {
var numRequests; var numRequests;
renderContainerPage(ABTestFixture, this); renderContainerPage(mockContainerXBlockHtml, this);
numRequests = requests.length; numRequests = requests.length;
// click delete on the first component // click delete on the first component but press no
clickDelete(0); clickDelete(0, true);
// click 'No' on delete confirmation
promptSpies.constructor.mostRecentCall.args[0].actions.secondary.click(promptSpies);
// all components should still exist // all components should still exist
expectComponents(getGroupElement(), allComponentsInGroup); expectComponents(getGroupElement(), allComponentsInGroup);
...@@ -297,11 +265,23 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers" ...@@ -297,11 +265,23 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
expect(requests.length).toBe(numRequests); expect(requests.length).toBe(numRequests);
}); });
it('does not delete xblock upon failure', function () { it('shows a notification during the delete operation', function() {
renderContainerPage(ABTestFixture, this); var notificationSpy = edit_helpers.createNotificationSpy();
deleteComponent(0, 500); renderContainerPage(mockContainerXBlockHtml, this);
clickDelete(0);
edit_helpers.verifyNotificationShowing(notificationSpy, /Deleting/);
create_sinon.respondWithJson(requests, {});
edit_helpers.verifyNotificationHidden(notificationSpy);
});
it('does not delete an xblock upon failure', function () {
var notificationSpy = edit_helpers.createNotificationSpy();
renderContainerPage(mockContainerXBlockHtml, this);
clickDelete(0);
edit_helpers.verifyNotificationShowing(notificationSpy, /Deleting/);
create_sinon.respondWithError(requests);
edit_helpers.verifyNotificationShowing(notificationSpy, /Deleting/);
expectComponents(getGroupElement(), allComponentsInGroup); expectComponents(getGroupElement(), allComponentsInGroup);
expect(notificationSpies.hide).not.toHaveBeenCalled();
}); });
}); });
...@@ -329,16 +309,8 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers" ...@@ -329,16 +309,8 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
// click duplicate button for given component // click duplicate button for given component
clickDuplicate(componentIndex); clickDuplicate(componentIndex);
// expect 'duplicating' notification to be shown
expectNotificationToBeShown(/Duplicating/);
// verify content of request // verify content of request
request = lastRequest(); request = lastRequest();
request.respond(
responseCode,
{ "Content-Type": "application/json" },
JSON.stringify({'locator': 'locator-duplicated-component'})
);
expect(request.url).toEqual("/xblock"); expect(request.url).toEqual("/xblock");
expect(request.method).toEqual("POST"); expect(request.method).toEqual("POST");
expect(JSON.parse(request.requestBody)).toEqual( expect(JSON.parse(request.requestBody)).toEqual(
...@@ -349,6 +321,13 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers" ...@@ -349,6 +321,13 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
'"}' '"}'
) )
); );
// send the response
request.respond(
responseCode,
{ "Content-Type": "application/json" },
JSON.stringify({'locator': 'locator-duplicated-component'})
);
}; };
duplicateComponentWithSuccess = function(componentIndex) { duplicateComponentWithSuccess = function(componentIndex) {
...@@ -356,34 +335,117 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers" ...@@ -356,34 +335,117 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
// duplicate component with an 'OK' response code // duplicate component with an 'OK' response code
duplicateComponentWithResponse(componentIndex, 200); duplicateComponentWithResponse(componentIndex, 200);
// expect 'duplicating' notification to be hidden
expect(notificationSpies.hide).toHaveBeenCalled();
// expect parent container to be refreshed // expect parent container to be refreshed
expect(refreshXBlockSpies).toHaveBeenCalled(); expect(refreshXBlockSpies).toHaveBeenCalled();
}; };
it("duplicates first xblock", function() { it("can duplicate the first xblock", function() {
renderContainerPage(ABTestFixture, this); renderContainerPage(mockContainerXBlockHtml, this);
duplicateComponentWithSuccess(0); duplicateComponentWithSuccess(0);
}); });
it("duplicates middle xblock", function() { it("can duplicate a middle xblock", function() {
renderContainerPage(ABTestFixture, this); renderContainerPage(mockContainerXBlockHtml, this);
duplicateComponentWithSuccess(1); duplicateComponentWithSuccess(1);
}); });
it("duplicates last xblock", function() { it("can duplicate the last xblock", function() {
renderContainerPage(ABTestFixture, this); renderContainerPage(mockContainerXBlockHtml, this);
duplicateComponentWithSuccess(NUM_COMPONENTS_PER_GROUP - 1); duplicateComponentWithSuccess(NUM_COMPONENTS_PER_GROUP - 1);
}); });
it('does not duplicate xblock upon failure', function () { it('shows a notification when duplicating', function () {
renderContainerPage(ABTestFixture, this); var notificationSpy = edit_helpers.createNotificationSpy();
duplicateComponentWithResponse(0, 500); renderContainerPage(mockContainerXBlockHtml, this);
clickDuplicate(0);
edit_helpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
create_sinon.respondWithJson(requests, {"locator": "new_item"});
edit_helpers.verifyNotificationHidden(notificationSpy);
});
it('does not duplicate an xblock upon failure', function () {
var notificationSpy = edit_helpers.createNotificationSpy();
renderContainerPage(mockContainerXBlockHtml, this);
clickDuplicate(0);
edit_helpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
create_sinon.respondWithError(requests);
expectComponents(getGroupElement(), allComponentsInGroup); expectComponents(getGroupElement(), allComponentsInGroup);
expect(notificationSpies.hide).not.toHaveBeenCalled();
expect(refreshXBlockSpies).not.toHaveBeenCalled(); expect(refreshXBlockSpies).not.toHaveBeenCalled();
edit_helpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
});
});
describe('createNewComponent ', function () {
var clickNewComponent, verifyComponents;
clickNewComponent = function (index) {
containerPage.$(".new-component .new-component-type a.single-template")[index].click();
};
it('sends the correct JSON to the server', function () {
renderContainerPage(mockContainerXBlockHtml, this);
clickNewComponent(0);
edit_helpers.verifyXBlockRequest(requests, {
"category": "discussion",
"type": "discussion",
"parent_locator": "locator-group-A"
});
});
it('shows a notification while creating', function () {
var notificationSpy = edit_helpers.createNotificationSpy();
renderContainerPage(mockContainerXBlockHtml, this);
clickNewComponent(0);
edit_helpers.verifyNotificationShowing(notificationSpy, /Adding/);
create_sinon.respondWithJson(requests, { });
edit_helpers.verifyNotificationHidden(notificationSpy);
});
it('does not insert component upon failure', function () {
var requestCount;
renderContainerPage(mockContainerXBlockHtml, this);
clickNewComponent(0);
requestCount = requests.length;
create_sinon.respondWithError(requests);
// No new requests should be made to refresh the view
expect(requests.length).toBe(requestCount);
expectComponents(getGroupElement(), allComponentsInGroup);
});
describe('Template Picker', function() {
var showTemplatePicker, verifyCreateHtmlComponent,
mockXBlockHtml = readFixtures('mock/mock-xblock.underscore');
showTemplatePicker = function() {
containerPage.$('.new-component .new-component-type a.multiple-templates')[0].click();
};
verifyCreateHtmlComponent = function(test, templateIndex, expectedRequest) {
var xblockCount;
renderContainerPage(mockContainerXBlockHtml, test);
showTemplatePicker();
xblockCount = containerPage.$('.studio-xblock-wrapper').length;
containerPage.$('.new-component-html a')[templateIndex].click();
edit_helpers.verifyXBlockRequest(requests, expectedRequest);
create_sinon.respondWithJson(requests, {"locator": "new_item"});
respondWithHtml(mockXBlockHtml);
expect(containerPage.$('.studio-xblock-wrapper').length).toBe(xblockCount + 1);
};
it('can add an HTML component without a template', function() {
verifyCreateHtmlComponent(this, 0, {
"category": "html",
"parent_locator": "locator-group-A"
});
});
it('can add an HTML component with a template', function() {
verifyCreateHtmlComponent(this, 1, {
"category": "html",
"boilerplate" : "announcement.yaml",
"parent_locator": "locator-group-A"
});
});
}); });
}); });
}); });
......
define(["coffee/src/views/unit", "js/models/module_info", "js/spec_helpers/create_sinon", "js/views/feedback_notification", define(["jquery", "underscore", "jasmine", "coffee/src/views/unit", "js/models/module_info",
"jasmine-stealth"], "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers", "jasmine-stealth"],
function (UnitEditView, ModuleModel, create_sinon, NotificationView) { function ($, _, jasmine, UnitEditView, ModuleModel, create_sinon, edit_helpers) {
var verifyJSON = function (requests, json) { var requests, unitView, initialize, respondWithHtml, verifyComponents, i;
var request = requests[requests.length - 1];
expect(request.url).toEqual("/xblock"); respondWithHtml = function(html, requestIndex) {
expect(request.method).toEqual("POST"); create_sinon.respondWithJson(
// There was a problem with order of returned parameters in strings. requests,
// Changed to compare objects instead strings. { html: html, "resources": [] },
expect(JSON.parse(request.requestBody)).toEqual(JSON.parse(json)); requestIndex
);
}; };
var verifyComponents = function (unit, locators) { initialize = function(test) {
var mockXBlockHtml = readFixtures('mock/mock-unit-page-xblock.underscore'),
model;
requests = create_sinon.requests(test);
model = new ModuleModel({
id: 'unit_locator',
state: 'draft'
});
unitView = new UnitEditView({
el: $('.main-wrapper'),
templates: edit_helpers.mockComponentTemplates,
model: model
});
// Respond with renderings for the two xblocks in the unit
respondWithHtml(mockXBlockHtml, 0);
respondWithHtml(mockXBlockHtml, 1);
};
verifyComponents = function (unit, locators) {
var components = unit.$(".component"); var components = unit.$(".component");
expect(components.length).toBe(locators.length); expect(components.length).toBe(locators.length);
for (var i=0; i < locators.length; i++) { for (i = 0; i < locators.length; i++) {
expect($(components[i]).data('locator')).toBe(locators[i]); expect($(components[i]).data('locator')).toBe(locators[i]);
} }
}; };
var verifyNotification = function (notificationSpy, text, requests) { beforeEach(function() {
expect(notificationSpy.constructor).toHaveBeenCalled(); edit_helpers.installMockXBlock();
expect(notificationSpy.show).toHaveBeenCalled();
expect(notificationSpy.hide).not.toHaveBeenCalled();
var options = notificationSpy.constructor.mostRecentCall.args[0];
expect(options.title).toMatch(text);
create_sinon.respondWithJson(requests, {"locator": "new_item"});
expect(notificationSpy.hide).toHaveBeenCalled();
};
describe('duplicateComponent ', function () { // needed to stub out the ajax
var duplicateFixture = window.analytics = jasmine.createSpyObj('analytics', ['track']);
'<div class="main-wrapper edit-state-draft" data-locator="unit_locator"> \ window.course_location_analytics = jasmine.createSpy('course_location_analytics');
<ol class="components"> \ window.unit_location_analytics = jasmine.createSpy('unit_location_analytics');
<li class="component" data-locator="loc_1"> \
<div class="wrapper wrapper-component-editor"/> \
<ul class="component-actions"> \
<a href="#" data-tooltip="Duplicate" class="duplicate-button action-button"><i class="icon-copy"></i><span class="sr"></span>Duplicate</span></a> \
</ul> \
</li> \
<li class="component" data-locator="loc_2"> \
<div class="wrapper wrapper-component-editor"/> \
<ul class="component-actions"> \
<a href="#" data-tooltip="Duplicate" class="duplicate-button action-button"><i class="icon-copy"></i><span class="sr"></span>Duplicate</span></a> \
</ul> \
</li> \
</ol> \
</div>';
var unit;
var clickDuplicate = function (index) {
unit.$(".duplicate-button")[index].click();
};
beforeEach(function () {
setFixtures(duplicateFixture);
unit = new UnitEditView({
el: $('.main-wrapper'),
model: new ModuleModel({
id: 'unit_locator',
state: 'draft'
})
}); });
afterEach(function () {
edit_helpers.uninstallMockXBlock();
}); });
describe("UnitEditView", function() {
beforeEach(function() {
edit_helpers.installEditTemplates();
appendSetFixtures(readFixtures('mock/mock-unit-page.underscore'));
});
describe('duplicateComponent', function() {
var clickDuplicate;
clickDuplicate = function (index) {
unitView.$(".duplicate-button")[index].click();
};
it('sends the correct JSON to the server', function () { it('sends the correct JSON to the server', function () {
var requests = create_sinon.requests(this); initialize(this);
clickDuplicate(0); clickDuplicate(0);
verifyJSON(requests, '{"duplicate_source_locator":"loc_1","parent_locator":"unit_locator"}'); edit_helpers.verifyXBlockRequest(requests, {
"duplicate_source_locator": "loc_1",
"parent_locator": "unit_locator"
});
}); });
it('inserts duplicated component immediately after source upon success', function () { it('inserts duplicated component immediately after source upon success', function () {
var requests = create_sinon.requests(this); initialize(this);
clickDuplicate(0); clickDuplicate(0);
create_sinon.respondWithJson(requests, {"locator": "duplicated_item"}); create_sinon.respondWithJson(requests, {"locator": "duplicated_item"});
verifyComponents(unit, ['loc_1', 'duplicated_item', 'loc_2']); verifyComponents(unitView, ['loc_1', 'duplicated_item', 'loc_2']);
}); });
it('inserts duplicated component at end if source at end', function () { it('inserts duplicated component at end if source at end', function () {
var requests = create_sinon.requests(this); initialize(this);
clickDuplicate(1); clickDuplicate(1);
create_sinon.respondWithJson(requests, {"locator": "duplicated_item"}); create_sinon.respondWithJson(requests, {"locator": "duplicated_item"});
verifyComponents(unit, ['loc_1', 'loc_2', 'duplicated_item']); verifyComponents(unitView, ['loc_1', 'loc_2', 'duplicated_item']);
}); });
it('shows a notification while duplicating', function () { it('shows a notification while duplicating', function () {
var notificationSpy = spyOnConstructor(NotificationView, "Mini", ["show", "hide"]); var notificationSpy = edit_helpers.createNotificationSpy();
notificationSpy.show.andReturn(notificationSpy); initialize(this);
var requests = create_sinon.requests(this);
clickDuplicate(0); clickDuplicate(0);
verifyNotification(notificationSpy, /Duplicating/, requests); edit_helpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
create_sinon.respondWithJson(requests, {"locator": "new_item"});
edit_helpers.verifyNotificationHidden(notificationSpy);
}); });
it('does not insert duplicated component upon failure', function () { it('does not insert duplicated component upon failure', function () {
var server = create_sinon.server(500, this); initialize(this);
clickDuplicate(0); clickDuplicate(0);
server.respond(); create_sinon.respondWithError(requests);
verifyComponents(unit, ['loc_1', 'loc_2']); verifyComponents(unitView, ['loc_1', 'loc_2']);
});
});
describe('saveNewComponent ', function () {
var newComponentFixture =
'<div class="main-wrapper edit-state-draft" data-locator="unit_locator"> \
<ol class="components"> \
<li class="component" data-locator="loc_1"> \
<div class="wrapper wrapper-component-editor"/> \
</li> \
<li class="component" data-locator="loc_2"> \
<div class="wrapper wrapper-component-editor"/> \
</li> \
<li class="new-component-item adding"> \
<div class="new-component"> \
<ul class="new-component-type"> \
<li> \
<a href="#" class="single-template" data-type="discussion" data-category="discussion"/> \
</li> \
</ul> \
</div> \
</li> \
</ol> \
</div>';
var unit;
var clickNewComponent = function () {
unit.$(".new-component .new-component-type a.single-template").click();
};
beforeEach(function () {
setFixtures(newComponentFixture);
unit = new UnitEditView({
el: $('.main-wrapper'),
model: new ModuleModel({
id: 'unit_locator',
state: 'draft'
})
}); });
}); });
describe('createNewComponent ', function () {
var clickNewComponent;
clickNewComponent = function () {
unitView.$(".new-component .new-component-type a.single-template").click();
};
it('sends the correct JSON to the server', function () { it('sends the correct JSON to the server', function () {
var requests = create_sinon.requests(this); initialize(this);
clickNewComponent(); clickNewComponent();
verifyJSON(requests, '{"category":"discussion","type":"discussion","parent_locator":"unit_locator"}'); edit_helpers.verifyXBlockRequest(requests, {
"category": "discussion",
"type": "discussion",
"parent_locator": "unit_locator"
});
}); });
it('inserts new component at end', function () { it('inserts new component at end', function () {
var requests = create_sinon.requests(this); initialize(this);
clickNewComponent(); clickNewComponent();
create_sinon.respondWithJson(requests, {"locator": "new_item"}); create_sinon.respondWithJson(requests, {"locator": "new_item"});
verifyComponents(unit, ['loc_1', 'loc_2', 'new_item']); verifyComponents(unitView, ['loc_1', 'loc_2', 'new_item']);
}); });
it('shows a notification while creating', function () { it('shows a notification while creating', function () {
var notificationSpy = spyOnConstructor(NotificationView, "Mini", ["show", "hide"]); var notificationSpy = edit_helpers.createNotificationSpy();
notificationSpy.show.andReturn(notificationSpy); initialize(this);
var requests = create_sinon.requests(this);
clickNewComponent(); clickNewComponent();
verifyNotification(notificationSpy, /Adding/, requests); edit_helpers.verifyNotificationShowing(notificationSpy, /Adding/);
create_sinon.respondWithJson(requests, {"locator": "new_item"});
edit_helpers.verifyNotificationHidden(notificationSpy);
}); });
it('does not insert duplicated component upon failure', function () { it('does not insert new component upon failure', function () {
var server = create_sinon.server(500, this); initialize(this);
clickNewComponent(); clickNewComponent();
server.respond(); create_sinon.respondWithError(requests);
verifyComponents(unit, ['loc_1', 'loc_2']); verifyComponents(unitView, ['loc_1', 'loc_2']);
}); });
}); });
describe("Disabled edit/publish links during ajax call", function() { describe("Disabled edit/publish links during ajax call", function() {
var unit, var link, i,
link,
draft_states = [ draft_states = [
{ {
state: "draft", state: "draft",
...@@ -174,67 +156,23 @@ define(["coffee/src/views/unit", "js/models/module_info", "js/spec_helpers/creat ...@@ -174,67 +156,23 @@ define(["coffee/src/views/unit", "js/models/module_info", "js/spec_helpers/creat
state: "public", state: "public",
selector: ".create-draft" selector: ".create-draft"
} }
], ];
editLinkFixture =
'<div class="main-wrapper edit-state-draft" data-locator="unit_locator"> \
<div class="unit-settings window"> \
<h4 class="header">Unit Settings</h4> \
<div class="window-contents"> \
<div class="row published-alert"> \
<p class="edit-draft-message"> \
<a href="#" class="create-draft">edit a draft</a> \
</p> \
<p class="publish-draft-message"> \
<a href="#" class="publish-draft">replace it with this draft</a> \
</p> \
</div> \
</div> \
</div> \
</div>';
function test_link_disabled_during_ajax_call(draft_state) {
beforeEach(function () {
setFixtures(editLinkFixture);
unit = new UnitEditView({
el: $('.main-wrapper'),
model: new ModuleModel({
id: 'unit_locator',
state: draft_state['state']
})
});
// needed to stub out the ajax
window.analytics = jasmine.createSpyObj('analytics', ['track']);
window.course_location_analytics = jasmine.createSpy('course_location_analytics');
window.unit_location_analytics = jasmine.createSpy('unit_location_analytics');
});
it("reenables the " + draft_state['selector'] + " link once the ajax call returns", function() { function test_link_disabled_during_ajax_call(draft_state) {
runs(function(){ it("re-enables the " + draft_state.selector + " link once the ajax call returns", function() {
spyOn($, "ajax").andCallThrough(); initialize(this);
spyOn($.fn, 'addClass').andCallThrough(); link = $(draft_state.selector);
spyOn($.fn, 'removeClass').andCallThrough(); expect(link).not.toHaveClass('is-disabled');
link = $(draft_state['selector']);
link.click(); link.click();
expect(link).toHaveClass('is-disabled');
create_sinon.respondWithError(requests);
expect(link).not.toHaveClass('is-disabled');
}); });
waitsFor(function(){ }
// wait for "is-disabled" to be removed as a class
return !($(draft_state['selector']).hasClass("is-disabled"));
}, 500);
runs(function(){
// check that the `is-disabled` class was added and removed
expect($.fn.addClass).toHaveBeenCalledWith("is-disabled");
expect($.fn.removeClass).toHaveBeenCalledWith("is-disabled");
// make sure the link finishes without the `is-disabled` class
expect(link).not.toHaveClass("is-disabled");
// affirm that ajax was called for (i = 0; i < draft_states.length; i++) {
expect($.ajax).toHaveBeenCalled(); test_link_disabled_during_ajax_call(draft_states[i]);
}
}); });
}); });
};
for (var i = 0; i < draft_states.length; i++) {
test_link_disabled_during_ajax_call(draft_states[i]);
};
}); });
}
);
...@@ -29,7 +29,7 @@ define([ "jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helper ...@@ -29,7 +29,7 @@ define([ "jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helper
var mockXBlockEditorHtml; var mockXBlockEditorHtml;
beforeEach(function () { beforeEach(function () {
edit_helpers.installMockXBlock(mockSaveResponse); edit_helpers.installMockXBlock();
}); });
afterEach(function() { afterEach(function() {
......
define(["sinon"], function(sinon) { define(["sinon", "underscore"], function(sinon, _) {
var fakeServer, fakeRequests, respondWithJson, respondWithError; var fakeServer, fakeRequests, respondWithJson, respondWithError;
/* These utility methods are used by Jasmine tests to create a mock server or /* These utility methods are used by Jasmine tests to create a mock server or
...@@ -46,14 +46,18 @@ define(["sinon"], function(sinon) { ...@@ -46,14 +46,18 @@ define(["sinon"], function(sinon) {
}; };
respondWithJson = function(requests, jsonResponse, requestIndex) { respondWithJson = function(requests, jsonResponse, requestIndex) {
requestIndex = requestIndex || requests.length - 1; if (_.isUndefined(requestIndex)) {
requestIndex = requests.length - 1;
}
requests[requestIndex].respond(200, requests[requestIndex].respond(200,
{ "Content-Type": "application/json" }, { "Content-Type": "application/json" },
JSON.stringify(jsonResponse)); JSON.stringify(jsonResponse));
}; };
respondWithError = function(requests, requestIndex) { respondWithError = function(requests, requestIndex) {
requestIndex = requestIndex || requests.length - 1; if (_.isUndefined(requestIndex)) {
requestIndex = requests.length - 1;
}
requests[requestIndex].respond(500, requests[requestIndex].respond(500,
{ "Content-Type": "application/json" }, { "Content-Type": "application/json" },
JSON.stringify({ })); JSON.stringify({ }));
......
...@@ -2,22 +2,14 @@ ...@@ -2,22 +2,14 @@
* Provides helper methods for invoking Studio editors in Jasmine tests. * Provides helper methods for invoking Studio editors in Jasmine tests.
*/ */
define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers/modal_helpers", define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers/modal_helpers",
"js/views/modals/edit_xblock", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"], "js/views/modals/edit_xblock", "js/collections/component_template",
function($, _, create_sinon, modal_helpers, EditXBlockModal) { "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
function($, _, create_sinon, modal_helpers, EditXBlockModal, ComponentTemplates) {
var editorTemplate = readFixtures('metadata-editor.underscore'), var installMockXBlock, uninstallMockXBlock, installMockXModule, uninstallMockXModule,
numberEntryTemplate = readFixtures('metadata-number-entry.underscore'), mockComponentTemplates, installEditTemplates, showEditModal, verifyXBlockRequest;
stringEntryTemplate = readFixtures('metadata-string-entry.underscore'),
editXBlockModalTemplate = readFixtures('edit-xblock-modal.underscore'),
editorModeButtonTemplate = readFixtures('editor-mode-button.underscore'),
installMockXBlock,
uninstallMockXBlock,
installMockXModule,
uninstallMockXModule,
installEditTemplates,
showEditModal;
installMockXBlock = function(mockResult) { installMockXBlock = function() {
window.MockXBlock = function(runtime, element) { window.MockXBlock = function(runtime, element) {
return { return {
runtime: runtime runtime: runtime
...@@ -41,17 +33,52 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers ...@@ -41,17 +33,52 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
window.MockDescriptor = null; window.MockDescriptor = null;
}; };
mockComponentTemplates = new ComponentTemplates([
{
templates: [
{
category: 'discussion',
display_name: 'Discussion'
}],
type: 'discussion'
}, {
"templates": [
{
"category": "html",
"boilerplate_name": null,
"display_name": "Text"
}, {
"category": "html",
"boilerplate_name": "announcement.yaml",
"display_name": "Announcement"
}, {
"category": "html",
"boilerplate_name": "raw.yaml",
"display_name": "Raw HTML"
}],
"type": "html"
}],
{
parse: true
});
installEditTemplates = function(append) { installEditTemplates = function(append) {
modal_helpers.installModalTemplates(append); modal_helpers.installModalTemplates(append);
// Add templates needed by the add XBlock menu
modal_helpers.installTemplate('add-xblock-component');
modal_helpers.installTemplate('add-xblock-component-button');
modal_helpers.installTemplate('add-xblock-component-menu');
modal_helpers.installTemplate('add-xblock-component-menu-problem');
// Add templates needed by the edit XBlock modal // Add templates needed by the edit XBlock modal
appendSetFixtures($("<script>", { id: "edit-xblock-modal-tpl", type: "text/template" }).text(editXBlockModalTemplate)); modal_helpers.installTemplate('edit-xblock-modal');
appendSetFixtures($("<script>", { id: "editor-mode-button-tpl", type: "text/template" }).text(editorModeButtonTemplate)); modal_helpers.installTemplate('editor-mode-button');
// Add templates needed by the settings editor // Add templates needed by the settings editor
appendSetFixtures($("<script>", {id: "metadata-editor-tpl", type: "text/template"}).text(editorTemplate)); modal_helpers.installTemplate('metadata-editor');
appendSetFixtures($("<script>", {id: "metadata-number-entry", type: "text/template"}).text(numberEntryTemplate)); modal_helpers.installTemplate('metadata-number-entry');
appendSetFixtures($("<script>", {id: "metadata-string-entry", type: "text/template"}).text(stringEntryTemplate)); modal_helpers.installTemplate('metadata-string-entry');
}; };
showEditModal = function(requests, xblockElement, model, mockHtml, options) { showEditModal = function(requests, xblockElement, model, mockHtml, options) {
...@@ -64,12 +91,22 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers ...@@ -64,12 +91,22 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
return modal; return modal;
}; };
verifyXBlockRequest = function (requests, expectedJson) {
var request = requests[requests.length - 1],
actualJson = JSON.parse(request.requestBody);
expect(request.url).toEqual("/xblock");
expect(request.method).toEqual("POST");
expect(actualJson).toEqual(expectedJson);
};
return $.extend(modal_helpers, { return $.extend(modal_helpers, {
'installMockXBlock': installMockXBlock, 'installMockXBlock': installMockXBlock,
'uninstallMockXBlock': uninstallMockXBlock, 'uninstallMockXBlock': uninstallMockXBlock,
'installMockXModule': installMockXModule, 'installMockXModule': installMockXModule,
'uninstallMockXModule': uninstallMockXModule, 'uninstallMockXModule': uninstallMockXModule,
'mockComponentTemplates': mockComponentTemplates,
'installEditTemplates': installEditTemplates, 'installEditTemplates': installEditTemplates,
'showEditModal': showEditModal 'showEditModal': showEditModal,
'verifyXBlockRequest': verifyXBlockRequest
}); });
}); });
...@@ -3,10 +3,7 @@ ...@@ -3,10 +3,7 @@
*/ */
define(["jquery", "js/spec_helpers/view_helpers"], define(["jquery", "js/spec_helpers/view_helpers"],
function($, view_helpers) { function($, view_helpers) {
var basicModalTemplate = readFixtures('basic-modal.underscore'), var installModalTemplates,
modalButtonTemplate = readFixtures('modal-button.underscore'),
feedbackTemplate = readFixtures('system-feedback.underscore'),
installModalTemplates,
getModalElement, getModalElement,
isShowingModal, isShowingModal,
hideModalIfShowing, hideModalIfShowing,
...@@ -15,8 +12,8 @@ define(["jquery", "js/spec_helpers/view_helpers"], ...@@ -15,8 +12,8 @@ define(["jquery", "js/spec_helpers/view_helpers"],
installModalTemplates = function(append) { installModalTemplates = function(append) {
view_helpers.installViewTemplates(append); view_helpers.installViewTemplates(append);
appendSetFixtures($("<script>", { id: "basic-modal-tpl", type: "text/template" }).text(basicModalTemplate)); view_helpers.installTemplate('basic-modal');
appendSetFixtures($("<script>", { id: "modal-button-tpl", type: "text/template" }).text(modalButtonTemplate)); view_helpers.installTemplate('modal-button');
}; };
getModalElement = function(modal) { getModalElement = function(modal) {
......
/** /**
* Provides helper methods for invoking Studio modal windows in Jasmine tests. * Provides helper methods for invoking Studio modal windows in Jasmine tests.
*/ */
define(["jquery"], define(["jquery", "js/views/feedback_notification", "js/spec_helpers/create_sinon"],
function($) { function($, NotificationView, create_sinon) {
var feedbackTemplate = readFixtures('system-feedback.underscore'), var installTemplate, installViewTemplates, createNotificationSpy, verifyNotificationShowing,
installViewTemplates; verifyNotificationHidden;
installViewTemplates = function(append) { installTemplate = function(templateName, isFirst) {
if (append) { var template = readFixtures(templateName + '.underscore'),
appendSetFixtures($("<script>", { id: "system-feedback-tpl", type: "text/template" }).text(feedbackTemplate)); templateId = templateName + '-tpl';
if (isFirst) {
setFixtures($("<script>", { id: templateId, type: "text/template" }).text(template));
} else { } else {
setFixtures($("<script>", { id: "system-feedback-tpl", type: "text/template" }).text(feedbackTemplate)); appendSetFixtures($("<script>", { id: templateId, type: "text/template" }).text(template));
} }
}; };
installViewTemplates = function(append) {
installTemplate('system-feedback', !append);
appendSetFixtures('<div id="page-notification"></div>');
};
createNotificationSpy = function() {
var notificationSpy = spyOnConstructor(NotificationView, "Mini", ["show", "hide"]);
notificationSpy.show.andReturn(notificationSpy);
return notificationSpy;
};
verifyNotificationShowing = function(notificationSpy, text) {
expect(notificationSpy.constructor).toHaveBeenCalled();
expect(notificationSpy.show).toHaveBeenCalled();
expect(notificationSpy.hide).not.toHaveBeenCalled();
var options = notificationSpy.constructor.mostRecentCall.args[0];
expect(options.title).toMatch(text);
};
verifyNotificationHidden = function(notificationSpy) {
expect(notificationSpy.hide).toHaveBeenCalled();
};
return { return {
'installViewTemplates': installViewTemplates 'installTemplate': installTemplate,
'installViewTemplates': installViewTemplates,
'createNotificationSpy': createNotificationSpy,
'verifyNotificationShowing': verifyNotificationShowing,
'verifyNotificationHidden': verifyNotificationHidden
}; };
}); });
...@@ -7,11 +7,11 @@ ...@@ -7,11 +7,11 @@
* getUpdateUrl: a utility method that returns the xblock update URL, appending * getUpdateUrl: a utility method that returns the xblock update URL, appending
* the location if passed in. * the location if passed in.
*/ */
define([], function () { define(["underscore"], function (_) {
var urlRoot = '/xblock'; var urlRoot = '/xblock';
var getUpdateUrl = function (locator) { var getUpdateUrl = function (locator) {
if (locator === undefined) { if (_.isUndefined(locator)) {
return urlRoot; return urlRoot;
} }
else { else {
......
define(["jquery", "underscore"], function($, _) {
/**
* Loads the named template from the page, or logs an error if it fails.
* @param name The name of the template.
* @returns The loaded template.
*/
var loadTemplate = function(name) {
var templateSelector = "#" + name + "-tpl",
templateText = $(templateSelector).text();
if (!templateText) {
console.error("Failed to load " + name + " template");
}
return _.template(templateText);
};
return {
loadTemplate: loadTemplate
};
});
define(["jquery", "underscore", "backbone", "js/utils/handle_iframe_binding"], define(["jquery", "underscore", "backbone", "gettext", "js/utils/handle_iframe_binding", "js/utils/templates",
function ($, _, Backbone, IframeUtils) { "js/views/feedback_notification", "js/views/feedback_prompt"],
function ($, _, Backbone, gettext, IframeUtils, TemplateUtils, NotificationView, PromptView) {
/* /*
This view is extended from backbone to provide useful functionality for all Studio views. This view is extended from backbone to provide useful functionality for all Studio views.
This functionality includes: This functionality includes:
...@@ -61,15 +62,59 @@ define(["jquery", "underscore", "backbone", "js/utils/handle_iframe_binding"], ...@@ -61,15 +62,59 @@ define(["jquery", "underscore", "backbone", "js/utils/handle_iframe_binding"],
}, },
/** /**
* Confirms with the user whether to run an operation or not, and then runs it if desired.
*/
confirmThenRunOperation: function(title, message, actionLabel, operation) {
var self = this;
return new PromptView.Warning({
title: title,
message: message,
actions: {
primary: {
text: actionLabel,
click: function(prompt) {
prompt.hide();
operation();
}
},
secondary: {
text: gettext('Cancel'),
click: function(prompt) {
return prompt.hide();
}
}
}
}).show();
},
/**
* Shows a progress message for the duration of an asynchronous operation.
* Note: this does not remove the notification upon failure because an error
* will be shown that shouldn't be removed.
* @param message The message to show.
* @param operation A function that returns a promise representing the operation.
*/
runOperationShowingMessage: function(message, operation) {
var notificationView;
notificationView = new NotificationView.Mini({
title: gettext(message)
});
notificationView.show();
return operation().done(function() {
notificationView.hide();
});
},
/**
* Disables a given element when a given operation is running. * Disables a given element when a given operation is running.
* @param {jQuery} element: the element to be disabled. * @param {jQuery} element: the element to be disabled.
* @param operation: the operation during whose duration the * @param operation: the operation during whose duration the
* element should be disabled. The operation should return * element should be disabled. The operation should return
* a jquery promise. * a JQuery promise.
*/ */
disableElementWhileRunning: function(element, operation) { disableElementWhileRunning: function(element, operation) {
element.addClass("is-disabled"); element.addClass("is-disabled");
operation().always(function() { return operation().always(function() {
element.removeClass("is-disabled"); element.removeClass("is-disabled");
}); });
}, },
...@@ -80,12 +125,38 @@ define(["jquery", "underscore", "backbone", "js/utils/handle_iframe_binding"], ...@@ -80,12 +125,38 @@ define(["jquery", "underscore", "backbone", "js/utils/handle_iframe_binding"],
* @returns The loaded template. * @returns The loaded template.
*/ */
loadTemplate: function(name) { loadTemplate: function(name) {
var templateSelector = "#" + name + "-tpl", return TemplateUtils.loadTemplate(name);
templateText = $(templateSelector).text(); },
if (!templateText) {
console.error("Failed to load " + name + " template"); /**
} * Returns the relative position that the element is scrolled from the top of the view port.
return _.template(templateText); * @param element The element in question.
*/
getScrollOffset: function(element) {
var elementTop = element.offset().top;
return elementTop - $(window).scrollTop();
},
/**
* Scrolls the window so that the element is scrolled down to the specified relative position
* from the top of the view port.
* @param element The element in question.
* @param offset The amount by which the element should be scrolled from the top of the view port.
*/
setScrollOffset: function(element, offset) {
var elementTop = element.offset().top,
newScrollTop = elementTop - offset;
this.setScrollTop(newScrollTop);
},
/**
* Performs an animated scroll so that the window has the specified scroll top.
* @param scrollTop The desired scroll top for the window.
*/
setScrollTop: function(scrollTop) {
$('html, body').animate({
scrollTop: scrollTop
}, 500);
} }
}); });
......
/**
* This is a simple component that renders add buttons for all available XBlock template types.
*/
define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/components/add_xblock_button",
"js/views/components/add_xblock_menu"],
function ($, _, gettext, BaseView, AddXBlockButton, AddXBlockMenu) {
var AddXBlockComponent = BaseView.extend({
events: {
'click .new-component .new-component-type a.multiple-templates': 'showComponentTemplates',
'click .new-component .new-component-type a.single-template': 'createNewComponent',
'click .new-component .cancel-button': 'closeNewComponent',
'click .new-component-templates .new-component-template a': 'createNewComponent',
'click .new-component-templates .cancel-button': 'closeNewComponent'
},
initialize: function(options) {
BaseView.prototype.initialize.call(this, options);
this.template = this.loadTemplate('add-xblock-component');
},
render: function () {
if (!this.$el.html()) {
var that = this;
this.$el.html(this.template({}));
this.collection.each(
function (componentModel) {
var view, menu;
view = new AddXBlockButton({model: componentModel});
that.$el.find('.new-component-type').append(view.render().el);
menu = new AddXBlockMenu({model: componentModel});
that.$el.append(menu.render().el);
}
);
}
},
showComponentTemplates: function(event) {
var type;
event.preventDefault();
event.stopPropagation();
type = $(event.currentTarget).data('type');
this.$('.new-component').slideUp(250);
this.$('.new-component-' + type).slideDown(250);
},
closeNewComponent: function(event) {
event.preventDefault();
event.stopPropagation();
this.$('.new-component').slideDown(250);
this.$('.new-component-templates').slideUp(250);
},
createNewComponent: function(event) {
var self = this,
element = $(event.currentTarget),
saveData = element.data(),
oldOffset = this.getScrollOffset(this.$el);
event.preventDefault();
this.closeNewComponent(event);
this.runOperationShowingMessage(
gettext('Adding&hellip;'),
_.bind(this.options.createComponent, this, saveData, element)
).always(function() {
// Restore the scroll position of the buttons so that the new
// component appears above them.
self.setScrollOffset(self.$el, oldOffset);
});
}
});
return AddXBlockComponent;
}); // end define();
define(["js/views/baseview"],
function (BaseView) {
return BaseView.extend({
tagName: "li",
initialize: function () {
BaseView.prototype.initialize.call(this);
this.template = this.loadTemplate("add-xblock-component-button");
this.$el.html(this.template({type: this.model.type, templates: this.model.templates}));
}
});
}); // end define();
define(["jquery", "js/views/baseview"],
function ($, BaseView) {
return BaseView.extend({
className: function () {
return "new-component-templates new-component-" + this.model.type;
},
initialize: function () {
BaseView.prototype.initialize.call(this);
var template_name = this.model.type === "problem" ? "add-xblock-component-menu-problem" :
"add-xblock-component-menu";
this.template = this.loadTemplate(template_name);
this.$el.html(this.template({type: this.model.type, templates: this.model.templates}));
// Make the tabs on problems into "real tabs"
this.$('.tab-group').tabs();
}
});
}); // end define();
\ No newline at end of file
define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", "js/views/feedback_notification"], define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", "js/views/feedback_notification"],
function ($, _, XBlockView, ModuleUtils, gettext, NotificationView) { function ($, _, XBlockView, ModuleUtils, gettext, NotificationView) {
var reorderableClass = '.reorderable-container',
studioXBlockWrapperClass = '.studio-xblock-wrapper';
var ContainerView = XBlockView.extend({ var ContainerView = XBlockView.extend({
xblockReady: function () { xblockReady: function () {
XBlockView.prototype.xblockReady.call(this); XBlockView.prototype.xblockReady.call(this);
var verticalContainer = this.$('.vertical-container'), var reorderableContainer = this.$(reorderableClass),
alreadySortable = this.$('.ui-sortable'), alreadySortable = this.$('.ui-sortable'),
newParent, newParent,
oldParent, oldParent,
...@@ -12,13 +15,13 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", ...@@ -12,13 +15,13 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
alreadySortable.sortable("destroy"); alreadySortable.sortable("destroy");
verticalContainer.sortable({ reorderableContainer.sortable({
handle: '.drag-handle', handle: '.drag-handle',
stop: function (event, ui) { stop: function (event, ui) {
var saving, hideSaving, removeFromParent; var saving, hideSaving, removeFromParent;
if (oldParent === undefined) { if (_.isUndefined(oldParent)) {
// If no actual change occurred, // If no actual change occurred,
// oldParent will never have been set. // oldParent will never have been set.
return; return;
...@@ -55,7 +58,7 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", ...@@ -55,7 +58,7 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
// be null if the change is related to the list the element // be null if the change is related to the list the element
// was originally in (the case of a move within the same container // was originally in (the case of a move within the same container
// or the deletion from a container when moving to a new container). // or the deletion from a container when moving to a new container).
var parent = $(event.target).closest('.wrapper-xblock'); var parent = $(event.target).closest(studioXBlockWrapperClass);
if (ui.sender) { if (ui.sender) {
// Move to a new container (the addition part). // Move to a new container (the addition part).
newParent = parent; newParent = parent;
...@@ -69,8 +72,8 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", ...@@ -69,8 +72,8 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
placeholder: 'component-placeholder', placeholder: 'component-placeholder',
forcePlaceholderSize: true, forcePlaceholderSize: true,
axis: 'y', axis: 'y',
items: '> .vertical-element', items: '> .is-draggable',
connectWith: ".vertical-container", connectWith: reorderableClass,
tolerance: "pointer" tolerance: "pointer"
}); });
...@@ -79,10 +82,10 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", ...@@ -79,10 +82,10 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
reorder: function (targetParent, successCallback) { reorder: function (targetParent, successCallback) {
var children, childLocators; var children, childLocators;
// Find descendants with class "wrapper-xblock" whose parent == targetParent. // Find descendants with class "studio-xblock-wrapper" whose parent === targetParent.
// This is necessary to filter our grandchildren, great-grandchildren, etc. // This is necessary to filter our grandchildren, great-grandchildren, etc.
children = targetParent.find('.wrapper-xblock').filter(function () { children = targetParent.find(studioXBlockWrapperClass).filter(function () {
var parent = $(this).parent().closest('.wrapper-xblock'); var parent = $(this).parent().closest(studioXBlockWrapperClass);
return parent.data('locator') === targetParent.data('locator'); return parent.data('locator') === targetParent.data('locator');
}); });
...@@ -107,7 +110,10 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", ...@@ -107,7 +110,10 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
} }
} }
}); });
},
refresh: function() {
this.$(reorderableClass).sortable('refresh');
} }
}); });
......
define(["js/views/baseview", "underscore", "underscore.string", "jquery"], function(BaseView, _, str, $) { define(["jquery", "underscore", "underscore.string", "backbone", "js/utils/templates"],
var SystemFeedback = BaseView.extend({ function($, _, str, Backbone, TemplateUtils) {
var SystemFeedback = Backbone.View.extend({
options: { options: {
title: "", title: "",
message: "", message: "",
...@@ -39,17 +40,18 @@ define(["js/views/baseview", "underscore", "underscore.string", "jquery"], funct ...@@ -39,17 +40,18 @@ define(["js/views/baseview", "underscore", "underscore.string", "jquery"], funct
} }
*/ */
}, },
initialize: function() { initialize: function() {
if(!this.options.type) { if (!this.options.type) {
throw "SystemFeedback: type required (given " + throw "SystemFeedback: type required (given " +
JSON.stringify(this.options) + ")"; JSON.stringify(this.options) + ")";
} }
if(!this.options.intent) { if (!this.options.intent) {
throw "SystemFeedback: intent required (given " + throw "SystemFeedback: intent required (given " +
JSON.stringify(this.options) + ")"; JSON.stringify(this.options) + ")";
} }
this.template = this.loadTemplate("system-feedback"); this.template = TemplateUtils.loadTemplate("system-feedback");
this.setElement($("#page-"+this.options.type)); this.setElement($("#page-" + this.options.type));
// handle single "secondary" action // handle single "secondary" action
if (this.options.actions && this.options.actions.secondary && if (this.options.actions && this.options.actions.secondary &&
!_.isArray(this.options.actions.secondary)) { !_.isArray(this.options.actions.secondary)) {
...@@ -57,22 +59,23 @@ define(["js/views/baseview", "underscore", "underscore.string", "jquery"], funct ...@@ -57,22 +59,23 @@ define(["js/views/baseview", "underscore", "underscore.string", "jquery"], funct
} }
return this; return this;
}, },
// public API: show() and hide() // public API: show() and hide()
show: function() { show: function() {
clearTimeout(this.hideTimeout); clearTimeout(this.hideTimeout);
this.options.shown = true; this.options.shown = true;
this.shownAt = new Date(); this.shownAt = new Date();
this.render(); this.render();
if($.isNumeric(this.options.maxShown)) { if ($.isNumeric(this.options.maxShown)) {
this.hideTimeout = setTimeout(_.bind(this.hide, this), this.hideTimeout = setTimeout(_.bind(this.hide, this),
this.options.maxShown); this.options.maxShown);
} }
return this; return this;
}, },
hide: function() { hide: function() {
if(this.shownAt && $.isNumeric(this.options.minShown) && if (this.shownAt && $.isNumeric(this.options.minShown) &&
this.options.minShown > new Date() - this.shownAt) this.options.minShown > new Date() - this.shownAt) {
{
clearTimeout(this.hideTimeout); clearTimeout(this.hideTimeout);
this.hideTimeout = setTimeout(_.bind(this.hide, this), this.hideTimeout = setTimeout(_.bind(this.hide, this),
this.options.minShown - (new Date() - this.shownAt)); this.options.minShown - (new Date() - this.shownAt));
...@@ -83,55 +86,61 @@ define(["js/views/baseview", "underscore", "underscore.string", "jquery"], funct ...@@ -83,55 +86,61 @@ define(["js/views/baseview", "underscore", "underscore.string", "jquery"], funct
} }
return this; return this;
}, },
// the rest of the API should be considered semi-private // the rest of the API should be considered semi-private
events: { events: {
"click .action-close": "hide", "click .action-close": "hide",
"click .action-primary": "primaryClick", "click .action-primary": "primaryClick",
"click .action-secondary": "secondaryClick" "click .action-secondary": "secondaryClick"
}, },
render: function() { render: function() {
// there can be only one active view of a given type at a time: only // there can be only one active view of a given type at a time: only
// one alert, only one notification, only one prompt. Therefore, we'll // one alert, only one notification, only one prompt. Therefore, we'll
// use a singleton approach. // use a singleton approach.
var singleton = SystemFeedback["active_"+this.options.type]; var singleton = SystemFeedback["active_" + this.options.type];
if(singleton && singleton !== this) { if (singleton && singleton !== this) {
singleton.stopListening(); singleton.stopListening();
singleton.undelegateEvents(); singleton.undelegateEvents();
} }
this.$el.html(this.template(this.options)); this.$el.html(this.template(this.options));
SystemFeedback["active_"+this.options.type] = this; SystemFeedback["active_" + this.options.type] = this;
return this; return this;
}, },
primaryClick: function(event) { primaryClick: function(event) {
var actions = this.options.actions; var actions, primary;
if(!actions) { return; } actions = this.options.actions;
var primary = actions.primary; if (!actions) { return; }
if(!primary) { return; } primary = actions.primary;
if(primary.preventDefault !== false) { if (!primary) { return; }
if (primary.preventDefault !== false) {
event.preventDefault(); event.preventDefault();
} }
if(primary.click) { if (primary.click) {
primary.click.call(event.target, this, event); primary.click.call(event.target, this, event);
} }
}, },
secondaryClick: function(event) { secondaryClick: function(event) {
var actions = this.options.actions; var actions, secondaryList, secondary, i;
if(!actions) { return; } actions = this.options.actions;
var secondaryList = actions.secondary; if (!actions) { return; }
if(!secondaryList) { return; } secondaryList = actions.secondary;
if (!secondaryList) { return; }
// which secondary action was clicked? // which secondary action was clicked?
var i = 0; // default to the first secondary action (easier for testing) i = 0; // default to the first secondary action (easier for testing)
if(event && event.target) { if (event && event.target) {
i = _.indexOf(this.$(".action-secondary"), event.target); i = _.indexOf(this.$(".action-secondary"), event.target);
} }
var secondary = secondaryList[i]; secondary = secondaryList[i];
if(secondary.preventDefault !== false) { if (secondary.preventDefault !== false) {
event.preventDefault(); event.preventDefault();
} }
if(secondary.click) { if (secondary.click) {
secondary.click.call(event.target, this, event); secondary.click.call(event.target, this, event);
} }
} }
}); });
return SystemFeedback; return SystemFeedback;
}); });
/** /**
* XBlockContainerView is used to display an xblock which has children, and allows the * XBlockContainerPage is used to display Studio's container page for an xblock which has children.
* user to interact with the children. * This page allows the user to understand and manipulate the xblock and its children.
*/ */
define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js/views/feedback_prompt", "js/views/baseview", "js/views/container", "js/views/xblock", "js/views/modals/edit_xblock", "js/models/xblock_info"], define(["jquery", "underscore", "gettext", "js/views/feedback_notification",
function ($, _, gettext, NotificationView, PromptView, BaseView, ContainerView, XBlockView, EditXBlockModal, XBlockInfo) { "js/views/baseview", "js/views/container", "js/views/xblock", "js/views/components/add_xblock",
"js/views/modals/edit_xblock", "js/models/xblock_info"],
var XBlockContainerView = BaseView.extend({ function ($, _, gettext, NotificationView, BaseView, ContainerView, XBlockView, AddXBlockComponent,
EditXBlockModal, XBlockInfo) {
var XBlockContainerPage = BaseView.extend({
// takes XBlockInfo as a model // takes XBlockInfo as a model
view: 'container_preview', view: 'container_preview',
...@@ -39,7 +41,8 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js ...@@ -39,7 +41,8 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
success: function(xblock) { success: function(xblock) {
if (xblockView.hasChildXBlocks()) { if (xblockView.hasChildXBlocks()) {
xblockView.$el.removeClass('is-hidden'); xblockView.$el.removeClass('is-hidden');
self.addButtonActions(xblockView.$el); self.renderAddXBlockComponents();
self.onXBlockRefresh(xblockView);
} else { } else {
noContentElement.removeClass('is-hidden'); noContentElement.removeClass('is-hidden');
} }
...@@ -50,137 +53,176 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js ...@@ -50,137 +53,176 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
}, },
findXBlockElement: function(target) { findXBlockElement: function(target) {
return $(target).closest('[data-locator]'); return $(target).closest('.studio-xblock-wrapper');
}, },
getURLRoot: function() { getURLRoot: function() {
return this.xblockView.model.urlRoot; return this.xblockView.model.urlRoot;
}, },
onXBlockRefresh: function(xblockView) {
this.addButtonActions(xblockView.$el);
this.xblockView.refresh();
},
renderAddXBlockComponents: function() {
var self = this;
this.$('.add-xblock-component').each(function(index, element) {
var component = new AddXBlockComponent({
el: element,
createComponent: _.bind(self.createComponent, self),
collection: self.options.templates
});
component.render();
});
},
addButtonActions: function(element) { addButtonActions: function(element) {
var self = this; var self = this;
element.find('.edit-button').click(function(event) { element.find('.edit-button').click(function(event) {
var modal,
target = event.target,
xblockElement = self.findXBlockElement(target);
event.preventDefault(); event.preventDefault();
modal = new EditXBlockModal({ }); self.editComponent(self.findXBlockElement(event.target));
modal.edit(xblockElement, self.model,
{
refresh: function(xblockInfo) {
self.refreshXBlock(xblockInfo, xblockElement);
}
});
}); });
element.find('.duplicate-button').click(function(event) { element.find('.duplicate-button').click(function(event) {
event.preventDefault(); event.preventDefault();
self.duplicateComponent( self.duplicateComponent(self.findXBlockElement(event.target));
self.findXBlockElement(event.target)
);
}); });
element.find('.delete-button').click(function(event) { element.find('.delete-button').click(function(event) {
event.preventDefault(); event.preventDefault();
self.deleteComponent( self.deleteComponent(self.findXBlockElement(event.target));
self.findXBlockElement(event.target)
);
}); });
}, },
duplicateComponent: function(xblockElement) { editComponent: function(xblockElement) {
var self = this, var self = this,
parentElement = self.findXBlockElement(xblockElement.parent()), modal = new EditXBlockModal({ });
duplicating = new NotificationView.Mini({ modal.edit(xblockElement, this.model, {
title: gettext('Duplicating&hellip;') refresh: function() {
self.refreshXBlock(xblockElement);
}
});
},
createComponent: function(template, target) {
// A placeholder element is created in the correct location for the new xblock
// and then onNewXBlock will replace it with a rendering of the xblock. Note that
// for xblocks that can't be replaced inline, the entire parent will be refreshed.
var parentElement = this.findXBlockElement(target),
parentLocator = parentElement.data('locator'),
buttonPanel = target.closest('.add-xblock-component'),
listPanel = buttonPanel.prev(),
scrollOffset = this.getScrollOffset(buttonPanel),
placeholderElement = $('<div></div>').appendTo(listPanel),
requestData = _.extend(template, {
parent_locator: parentLocator
}); });
return $.postJSON(this.getURLRoot(), requestData,
_.bind(this.onNewXBlock, this, placeholderElement, scrollOffset));
},
duplicating.show(); duplicateComponent: function(xblockElement) {
return $.postJSON(self.getURLRoot(), { // A placeholder element is created in the correct location for the duplicate xblock
// and then onNewXBlock will replace it with a rendering of the xblock. Note that
// for xblocks that can't be replaced inline, the entire parent will be refreshed.
var self = this,
parent = xblockElement.parent();
this.runOperationShowingMessage(gettext('Duplicating&hellip;'),
function() {
var scrollOffset = self.getScrollOffset(xblockElement),
placeholderElement = $('<div></div>').insertAfter(xblockElement),
parentElement = self.findXBlockElement(parent),
requestData = {
duplicate_source_locator: xblockElement.data('locator'), duplicate_source_locator: xblockElement.data('locator'),
parent_locator: parentElement.data('locator') parent_locator: parentElement.data('locator')
}, function(data) { };
// copy the element return $.postJSON(self.getURLRoot(), requestData,
var duplicatedElement = xblockElement.clone(false); _.bind(self.onNewXBlock, self, placeholderElement, scrollOffset));
// place it after the original element
xblockElement.after(duplicatedElement);
// update its locator id
duplicatedElement.attr('data-locator', data.locator);
// have it refresh itself
self.refreshXBlockElement(duplicatedElement);
// hide the notification
duplicating.hide();
}); });
}, },
deleteComponent: function(xblockElement) { deleteComponent: function(xblockElement) {
var self = this, deleting; var self = this;
return new PromptView.Warning({ this.confirmThenRunOperation(gettext('Delete this component?'),
title: gettext('Delete this component?'), gettext('Deleting this component is permanent and cannot be undone.'),
message: gettext('Deleting this component is permanent and cannot be undone.'), gettext('Yes, delete this component'),
actions: { function() {
primary: { self.runOperationShowingMessage(gettext('Deleting&hellip;'),
text: gettext('Yes, delete this component'), function() {
click: function(prompt) {
prompt.hide();
deleting = new NotificationView.Mini({
title: gettext('Deleting&hellip;')
});
deleting.show();
return $.ajax({ return $.ajax({
type: 'DELETE', type: 'DELETE',
url: url: self.getURLRoot() + "/" +
self.getURLRoot() + "/" +
xblockElement.data('locator') + "?" + xblockElement.data('locator') + "?" +
$.param({recurse: true, all_versions: true}) $.param({recurse: true, all_versions: true})
}).success(function() { }).success(function() {
deleting.hide();
xblockElement.remove(); xblockElement.remove();
}); });
} });
}, });
secondary: {
text: gettext('Cancel'),
click: function(prompt) {
return prompt.hide();
}
}
}
}).show();
}, },
refreshXBlockElement: function(xblockElement) { onNewXBlock: function(xblockElement, scrollOffset, data) {
this.refreshXBlock( this.setScrollOffset(xblockElement, scrollOffset);
new XBlockInfo({ xblockElement.data('locator', data.locator);
id: xblockElement.data('locator') return this.refreshXBlock(xblockElement);
}),
xblockElement
);
}, },
refreshXBlock: function(xblockInfo, xblockElement) { /**
var self = this, temporaryView; * Refreshes the specified xblock's display. If the xblock is an inline child of a
* reorderable container then the element will be refreshed inline. If not, then the
* parent container will be refreshed instead.
* @param xblockElement The element representing the xblock to be refreshed.
*/
refreshXBlock: function(xblockElement) {
var parentElement = xblockElement.parent(),
rootLocator = this.xblockView.model.id,
xblockLocator = xblockElement.data('locator');
if (xblockLocator === rootLocator) {
this.render();
} else if (parentElement.hasClass('reorderable-container')) {
this.refreshChildXBlock(xblockElement);
} else {
this.refreshXBlock(this.findXBlockElement(parentElement));
}
},
/**
* Refresh an xblock element inline on the page, using the specified xblockInfo.
* Note that the element is removed and replaced with the newly rendered xblock.
* @param xblockElement The xblock element to be refreshed.
* @returns {promise} A promise representing the complete operation.
*/
refreshChildXBlock: function(xblockElement) {
var self = this,
xblockInfo,
TemporaryXBlockView,
temporaryView;
xblockInfo = new XBlockInfo({
id: xblockElement.data('locator')
});
// There is only one Backbone view created on the container page, which is // There is only one Backbone view created on the container page, which is
// for the container xblock itself. Any child xblocks rendered inside the // for the container xblock itself. Any child xblocks rendered inside the
// container do not get a Backbone view. Thus, create a temporary XBlock // container do not get a Backbone view. Thus, create a temporary view
// around the child element so that it can be refreshed. // to render the content, and then replace the original element with the result.
temporaryView = new XBlockView({ TemporaryXBlockView = XBlockView.extend({
el: xblockElement, updateHtml: function(element, html) {
// Replace the element with the new HTML content, rather than adding
// it as child elements.
this.$el = $(html).replaceAll(element);
}
});
temporaryView = new TemporaryXBlockView({
model: xblockInfo, model: xblockInfo,
view: this.view view: 'reorderable_container_child_preview',
el: xblockElement
}); });
temporaryView.render({ return temporaryView.render({
success: function() { success: function() {
self.onXBlockRefresh(temporaryView);
temporaryView.unbind(); // Remove the temporary view temporaryView.unbind(); // Remove the temporary view
self.addButtonActions(xblockElement);
} }
}); });
} }
}); });
return XBlockContainerView; return XBlockContainerPage;
}); // end define(); }); // end define();
...@@ -74,13 +74,24 @@ define(["jquery", "underscore", "js/views/baseview", "xblock/runtime.v1"], ...@@ -74,13 +74,24 @@ define(["jquery", "underscore", "js/views/baseview", "xblock/runtime.v1"],
if (!element) { if (!element) {
element = this.$el; element = this.$el;
} }
// First render the HTML as the scripts might depend upon it
element.html(html); // Render the HTML first as the scripts might depend upon it, and then
// Now asynchronously add the resources to the page // asynchronously add the resources to the page.
this.updateHtml(element, html);
return this.addXBlockFragmentResources(resources); return this.addXBlockFragmentResources(resources);
}, },
/** /**
* Updates an element to have the specified HTML. The default method sets the HTML
* as child content, but this can be overridden.
* @param element The element to be updated
* @param html The desired HTML.
*/
updateHtml: function(element, html) {
element.html(html);
},
/**
* Dynamically loads all of an XBlock's dependent resources. This is an asynchronous * Dynamically loads all of an XBlock's dependent resources. This is an asynchronous
* process so a promise is returned. * process so a promise is returned.
* @param resources The resources to be rendered * @param resources The resources to be rendered
......
...@@ -190,7 +190,7 @@ ...@@ -190,7 +190,7 @@
@include transition(all $tmg-f3 ease-in-out 0s); @include transition(all $tmg-f3 ease-in-out 0s);
position: fixed; position: fixed;
top: 0; top: 0;
background: $black-t0; background: $black-t1;
width: 100%; width: 100%;
height: 100%; height: 100%;
text-align: center; text-align: center;
...@@ -676,11 +676,6 @@ ...@@ -676,11 +676,6 @@
// prompt showing // prompt showing
&.prompt-is-shown { &.prompt-is-shown {
.wrapper-view {
-webkit-filter: blur(($baseline/10)) grayscale(25%);
filter: blur(($baseline/10)) grayscale(25%);
}
.wrapper-prompt.is-shown { .wrapper-prompt.is-shown {
visibility: visible; visibility: visible;
pointer-events: auto; pointer-events: auto;
...@@ -694,11 +689,6 @@ ...@@ -694,11 +689,6 @@
// prompt hiding // prompt hiding
&.prompt-is-hiding { &.prompt-is-hiding {
.wrapper-view {
-webkit-filter: blur(($baseline/10)) grayscale(25%);
filter: blur(($baseline/10)) grayscale(25%);
}
.wrapper-prompt { .wrapper-prompt {
.prompt { .prompt {
......
...@@ -48,10 +48,15 @@ ...@@ -48,10 +48,15 @@
// UI: xblocks - calls-to-action // UI: xblocks - calls-to-action
.wrapper-xblock .header-actions { .wrapper-xblock .header-actions {
@extend %actions-header; @extend %actions-header;
.action-button [class^="icon-"] {
font-style: normal;
}
} }
// UI: xblock is collapsible // UI: xblock is collapsible
.wrapper-xblock.is-collapsible, .wrapper-xblock.xblock-type-container { .wrapper-xblock.is-collapsible,
.wrapper-xblock.xblock-type-container {
[class^="icon-"] { [class^="icon-"] {
font-style: normal; font-style: normal;
......
...@@ -116,40 +116,6 @@ body.view-container .content-primary { ...@@ -116,40 +116,6 @@ body.view-container .content-primary {
border: 2px dashed $gray-l2; border: 2px dashed $gray-l2;
} }
.vert-mod {
// min-height to allow drop when empty
.vertical-container {
min-height: ($baseline*2.5);
}
.vert {
position: relative;
.drag-handle {
display: none; // only show when vert is draggable
position: absolute;
top: 0;
right: ($baseline/2); // equal to margin on component
width: ($baseline*1.5);
height: ($baseline*2.5);
margin: 0;
background: transparent url("../img/drag-handles.png") no-repeat scroll center center;
}
}
.is-draggable {
.xblock-header {
padding-right: ($baseline*1.5); // make room for drag handle
}
.drag-handle {
display: block;
}
}
}
.wrapper-xblock { .wrapper-xblock {
@extend %wrap-xblock; @extend %wrap-xblock;
...@@ -165,18 +131,17 @@ body.view-container .content-primary { ...@@ -165,18 +131,17 @@ body.view-container .content-primary {
// CASE: nesting level xblock rendering // CASE: nesting level xblock rendering
&.level-nesting { &.level-nesting {
@include transition(all $tmg-f2 linear 0s); @include transition(all $tmg-f2 linear 0s);
border: none; border: 1px solid $gray-l3;
padding-bottom: $baseline; padding-bottom: $baseline;
box-shadow: none;
&:hover { // min-height to allow drop when empty
background-color: $gray-l6; .reorderable-container {
box-shadow: 0 0 1px $shadow-d2 inset; min-height: $baseline;
} }
.xblock-header { .xblock-header {
@include ui-flexbox(); @include ui-flexbox();
margin-bottom: ($baseline/2); margin-bottom: 0;
border-bottom: none; border-bottom: none;
background: none; background: none;
} }
...@@ -230,6 +195,24 @@ body.view-container .content-primary { ...@@ -230,6 +195,24 @@ body.view-container .content-primary {
} }
} }
} }
// add a new component menu override - most styles currently live in _unit.scss
.new-component-item {
margin: $baseline ($baseline/2);
border: 1px solid $gray-l3;
border-radius: ($baseline/4);
box-shadow: 0 1px 3px $shadow inset;
background-color: $gray-l5;
padding: ($baseline/2);
h5 {
margin-bottom: ($baseline*.75);
}
.new-component-type a {
margin-bottom: ($baseline/2);
}
}
} }
// ==================== // ====================
......
// studio - views - unit // studio - views - unit
// ==================== // ====================
body.course.unit,.view-unit { body.course.unit,
.view-unit {
.main-wrapper { .main-wrapper {
margin-top: ($baseline*2); margin-top: ($baseline*2);
...@@ -91,271 +92,6 @@ body.course.unit,.view-unit { ...@@ -91,271 +92,6 @@ body.course.unit,.view-unit {
} }
} }
// ====================
// New Components
&.new-component-item {
margin: $baseline 0px;
border-top: 1px solid $mediumGrey;
box-shadow: 0 2px 1px rgba(182, 182, 182, 0.75) inset;
background-color: $lightGrey;
margin-bottom: 0px;
padding-bottom: $baseline;
.new-component-button {
display: block;
padding: $baseline;
text-align: center;
color: #edf1f5;
}
h5 {
margin: $baseline 0px;
color: #fff;
font-weight: 600;
font-size: 18px;
}
.rendered-component {
display: none;
background: #fff;
border-radius: 3px 3px 0 0;
}
.new-component-type {
a,
li {
display: inline-block;
}
a {
border: 1px solid $mediumGrey;
width: 100px;
height: 100px;
color: #fff;
margin-right: 15px;
margin-bottom: $baseline;
border-radius: 8px;
font-size: 15px;
line-height: 14px;
text-align: center;
box-shadow: 0 1px 1px rgba(0, 0, 0, .2), 0 1px 0 rgba(255, 255, 255, .4) inset;
.name {
position: absolute;
bottom: 5px;
left: 0;
width: 100%;
padding: $baseline/2;
@include box-sizing(border-box);
color: #fff;
}
}
}
.new-component-templates {
display: none;
margin: $baseline 2*$baseline;
border-radius: 3px;
border: 1px solid $mediumGrey;
background-color: #fff;
box-shadow: 0 1px 1px rgba(0, 0, 0, .2), 0 1px 0 rgba(255, 255, 255, .4) inset;
@include clearfix;
.cancel-button {
margin: $baseline 0px $baseline/2 $baseline/2;
@include white-button;
}
.problem-type-tabs {
display: none;
}
// specific menu types
&.new-component-problem {
padding-bottom: $baseline/2;
[class^="icon-"], .editor-indicator {
display: inline-block;
}
.problem-type-tabs {
display: inline-block;
}
}
}
.new-component-type,
.new-component-template {
@include clearfix;
a {
position: relative;
border: 1px solid $darkGreen;
background: tint($green,20%);
color: #fff;
&:hover {
background: $brightGreen;
}
}
}
.problem-type-tabs {
list-style-type: none;
border-radius: 0;
width: 100%;
@include linear-gradient(top, rgba(255, 255, 255, .4), rgba(255, 255, 255, 0));
background-color: $lightBluishGrey;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.2) inset, 0 -1px 0 rgba(0, 0, 0, 0.2) inset;
li:first-child {
margin-left: $baseline;
}
li {
float:left;
display:inline-block;
text-align:center;
width: auto;
@include linear-gradient(top, rgba(255, 255, 255, .4), rgba(255, 255, 255, 0));
background-color: tint($lightBluishGrey, 10%);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.2) inset, 0 -1px 0 rgba(0, 0, 0, 0.2) inset;
opacity: 0.8;
&:hover {
opacity: 0.9;
background-color: tint($lightBluishGrey, 20%);
}
&.ui-state-active {
border: 0px;
@include active;
opacity: 1.0;
}
}
a {
display: block;
padding: 15px 25px;
font-size: 15px;
line-height: 16px;
text-align: center;
color: #3c3c3c;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.3);
}
}
.new-component-template {
a {
@include transition(none);
background: #fff;
border: 0px;
color: #3c3c3c;
&:hover {
@include transition(background-color $tmg-f2 linear 0s);
background: tint($green,30%);
color: #fff;
}
}
li {
border:none;
border-bottom: 1px dashed $lightGrey;
color: #fff;
}
li:first-child {
a {
border-top: 0px;
}
}
li:nth-child(2) {
a {
border-radius: 0px;
}
}
a {
@include clearfix();
display: block;
padding: 7px $baseline;
border-bottom: none;
font-weight: 500;
.name {
float: left;
[class^="icon-"] {
@include transition(opacity $tmg-f2 linear 0s);
display: inline-block;
top: 1px;
margin-right: 5px;
opacity: 0.5;
width: 17;
height: 21px;
vertical-align: middle;
}
}
.editor-indicator {
@include transition(opacity $tmg-f2 linear 0s);
float: right;
position: relative;
top: 3px;
font-size: 12px;
opacity: 0.3;
}
[class^="icon-"], .editor-indicator {
display: none;
}
&:hover {
color: #fff;
[class^="icon-"] {
opacity: 1.0;
}
.editor-indicator {
opacity: 1.0;
}
}
}
// specific editor types
.empty {
a {
line-height: 1.4;
font-weight: 400;
background: #fff;
color: #3c3c3c;
&:hover {
background: tint($green,30%);
color: #fff;
}
}
}
}
.new-component {
text-align: center;
h5 {
color: $darkGreen;
}
}
}
.wrapper-alert-error { .wrapper-alert-error {
margin-top: ($baseline*1.25); margin-top: ($baseline*1.25);
box-shadow: none; box-shadow: none;
...@@ -365,10 +101,7 @@ body.course.unit,.view-unit { ...@@ -365,10 +101,7 @@ body.course.unit,.view-unit {
.title { .title {
color: $white; color: $white;
} }
} }
} }
} }
...@@ -1444,3 +1177,260 @@ body.unit .component.editing { ...@@ -1444,3 +1177,260 @@ body.unit .component.editing {
margin-top: 0; margin-top: 0;
} }
} }
body.view-unit .main-column .unit-body,
body.view-container {
// New Components
.new-component-item {
margin: $baseline 0 0 0;
border-top: 1px solid $gray-l3;
box-shadow: 0 2px 1px $shadow-l1 inset;
background-color: $lightGrey;
padding: $baseline;
.new-component {
text-align: center;
h5 {
color: $darkGreen;
}
}
.new-component-button {
display: block;
padding: $baseline;
text-align: center;
color: $green;
}
h5 {
@extend %t-title5;
margin: 0 0 $baseline 0;
color: $white;
font-weight: 600;
}
.rendered-component {
display: none;
background: $white;
border-radius: 3px 3px 0 0;
}
.new-component-type {
a,
li {
display: inline-block;
}
a {
@extend %t-action3;
width: ($baseline*5);
height: ($baseline*5);
margin-right: ($baseline*.75);
margin-bottom: $baseline;
border: 1px solid $mediumGrey;
border-radius: ($baseline/4);
box-shadow: 0 1px 1px $shadow, 0 1px 0 rgba(255, 255, 255, .4) inset;
text-align: center;
color: $white;
.name {
@include box-sizing(border-box);
display: block;
color: $white;
}
}
}
.new-component-templates {
@include clearfix;
display: none;
margin: $baseline ($baseline*2);
border-radius: 3px;
border: 1px solid $mediumGrey;
background-color: $white;
box-shadow: 0 1px 1px $shadow, 0 1px 0 rgba(255, 255, 255, .4) inset;
.cancel-button {
@include white-button;
margin: $baseline 0 ($baseline/2) ($baseline/2);
}
.problem-type-tabs {
display: none;
}
// specific menu types
&.new-component-problem {
padding-bottom: ($baseline/2);
[class^="icon-"], .editor-indicator {
display: inline-block;
}
.problem-type-tabs {
display: inline-block;
}
}
}
.new-component-type,
.new-component-template {
@include clearfix;
a {
position: relative;
border: 1px solid $green-d2;
background-color: $green-l1;
color: $white;
&:hover {
background: $green-s1;
}
}
}
.problem-type-tabs {
@include linear-gradient(top, rgba(255, 255, 255, .4), rgba(255, 255, 255, 0));
list-style-type: none;
width: 100%;
border-radius: 0;
background-color: $lightBluishGrey;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.2) inset, 0 -1px 0 $shadow inset;
li:first-child {
margin-left: $baseline;
}
li {
@include linear-gradient(top, rgba(255, 255, 255, .4), rgba(255, 255, 255, 0));
opacity: 0.8;
float: left;
display: inline-block;
width: auto;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.2) inset, 0 -1px 0 $shadow inset;
background-color: tint($lightBluishGrey, 10%);
text-align: center;
&:hover {
opacity: 0.9;
background-color: tint($lightBluishGrey, 20%);
}
&.ui-state-active {
@include active;
border: 0px;
opacity: 1.0;
}
}
a {
@extend %t-action3;
display: block;
padding: ($baseline*.75) ($baseline*1.25);
text-align: center;
color: $gray-d3;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.3);
}
}
.new-component-template {
a {
@include transition(none);
border: 0px;
background: $white;
color: $gray-d3;
&:hover {
@include transition(background-color $tmg-f2 linear 0s);
background: tint($green,30%);
color: $white;
}
}
li {
border:none;
border-bottom: 1px dashed $lightGrey;
color: $white;
}
li:first-child a {
border-top: 0;
}
li:nth-child(2) a {
border-radius: 0;
}
a {
@include clearfix();
display: block;
padding: 7px $baseline;
border-bottom: none;
font-weight: 500;
.name {
float: left;
[class^="icon-"] {
@include transition(opacity $tmg-f2 linear 0s);
display: inline-block;
top: 1px;
margin-right: 5px;
opacity: 0.5;
width: 17;
height: 21px;
vertical-align: middle;
}
}
.editor-indicator {
@extend %t-copy-sub2;
@include transition(opacity $tmg-f2 linear 0s);
float: right;
position: relative;
top: 3px;
opacity: 0.3;
}
[class^="icon-"], .editor-indicator {
display: none;
}
&:hover {
color: $white;
[class^="icon-"] {
opacity: 1.0;
}
.editor-indicator {
opacity: 1.0;
}
}
}
// specific editor types
.empty {
a {
background: $white;
line-height: 1.4;
font-weight: 400;
color: $gray-d3;
&:hover {
background: tint($green,30%);
color: $white;
}
}
}
}
}
}
...@@ -26,7 +26,5 @@ ...@@ -26,7 +26,5 @@
</li> </li>
</ul> </ul>
</div> </div>
% if not xblock_context['read_only']: <span data-tooltip="${_("Drag to reorder")}" class="drag-handle action"></span>
<span data-tooltip="${_("Drag to reorder")}" class="drag-handle"></span>
% endif
${preview} ${preview}
...@@ -31,15 +31,18 @@ main_xblock_info = { ...@@ -31,15 +31,18 @@ main_xblock_info = {
%> %>
<script type='text/javascript'> <script type='text/javascript'>
require(["domReady!", "jquery", "js/models/xblock_info", "js/views/pages/container", require(["domReady!", "jquery", "js/models/xblock_info", "js/views/pages/container",
"xmodule", "coffee/src/main", "xblock/cms.runtime.v1"], "js/collections/component_template", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
function(doc, $, XBlockInfo, ContainerPage) { function(doc, $, XBlockInfo, ContainerPage, ComponentTemplates) {
var view, mainXBlockInfo; var view, mainXBlockInfo;
var templates = new ComponentTemplates(${component_templates | n}, {parse: true});
mainXBlockInfo = new XBlockInfo(${json.dumps(main_xblock_info) | n}); mainXBlockInfo = new XBlockInfo(${json.dumps(main_xblock_info) | n});
view = new ContainerPage({ view = new ContainerPage({
el: $('#content'), el: $('#content'),
model: mainXBlockInfo model: mainXBlockInfo,
templates: templates
}); });
view.render(); view.render();
}); });
...@@ -80,7 +83,7 @@ main_xblock_info = { ...@@ -80,7 +83,7 @@ main_xblock_info = {
<section class="content-area"> <section class="content-area">
<article class="content-primary window"> <article class="content-primary window">
<section class="wrapper-xblock level-page is-hidden" data-locator="${xblock_locator}"> <section class="wrapper-xblock level-page is-hidden studio-xblock-wrapper" data-locator="${xblock_locator}">
</section> </section>
<div class="no-container-content is-hidden"> <div class="no-container-content is-hidden">
<p>${_("This page has no content yet.")}</p> <p>${_("This page has no content yet.")}</p>
......
...@@ -18,10 +18,10 @@ from contentstore.views.helpers import xblock_studio_url ...@@ -18,10 +18,10 @@ from contentstore.views.helpers import xblock_studio_url
<i class="icon-arrow-right"></i> <i class="icon-arrow-right"></i>
</a> </a>
</li> </li>
<li class="action-item action-drag">
<span data-tooltip="${_('Drag to reorder')}" class="drag-handle action"></span>
</li>
</ul> </ul>
</div> </div>
</header> </header>
% if not xblock_context['read_only']:
<span data-tooltip="${_("Drag to reorder")}" class="drag-handle"></span>
% endif
</section> </section>
...@@ -125,7 +125,7 @@ ...@@ -125,7 +125,7 @@
</div> </div>
% if tab.is_movable: % if tab.is_movable:
<div class="drag-handle" data-tooltip="${_('Drag to reorder')}"> <div class="drag-handle action" data-tooltip="${_('Drag to reorder')}">
<span class="sr">${_("Drag to reorder")}</span> <span class="sr">${_("Drag to reorder")}</span>
</div> </div>
% else: % else:
......
<% if (type === 'advanced' || templates.length > 1) { %>
<a href="#" class="multiple-templates" data-type="<%= type %>">
<% } else { %>
<a href="#" class="single-template" data-type="<%= type %>" data-category="<%= templates[0].category %>">
<% } %>
<span class="large-template-icon large-<%= type %>-icon"></span>
<span class="name"><%= type %></span>
</a>
<div class="tab-group tabs">
<ul class="problem-type-tabs nav-tabs">
<li class="current">
<a class="link-tab" href="#tab1"><%= gettext("Common Problem Types") %></a>
</li>
<li>
<a class="link-tab" href="#tab2"><%= gettext("Advanced") %></a>
</li>
</ul>
<div class="tab current" id="tab1">
<ul class="new-component-template">
<% for (var i = 0; i < templates.length; i++) { %>
<% if (templates[i].is_common) { %>
<% if (!templates[i].boilerplate_name) { %>
<li class="editor-md empty">
<a href="#" data-category="<%= templates[i].category %>">
<span class="name"><%= templates[i].display_name %></span>
</a>
</li>
<% } else { %>
<li class="editor-md">
<a href="#" data-category="<%= templates[i].category %>"
data-boilerplate="<%= templates[i].boilerplate_name %>">
<span class="name"><%= templates[i].display_name %></span>
</a>
</li>
<% } %>
<% } %>
<% } %>
</ul>
</div>
<div class="tab" id="tab2">
<ul class="new-component-template">
<% for (var i = 0; i < templates.length; i++) { %>
<% if (!templates[i].is_common) { %>
<li class="editor-manual">
<a href="#" data-category="<%= templates[i].category %>"
data-boilerplate="<%= templates[i].boilerplate_name %>">
<span class="name"><%= templates[i].display_name %></span>
</a>
</li>
<% } %>
<% } %>
</ul>
</div>
</div>
<a href="#" class="cancel-button"><%= gettext("Cancel") %></a>
<% if (type === 'advanced' || templates.length > 1) { %>
<div class="tab current" id="tab1">
<ul class="new-component-template">
<% for (var i = 0; i < templates.length; i++) { %>
<% if (!templates[i].boilerplate_name) { %>
<li class="editor-md empty">
<a href="#" data-category="<%= templates[i].category %>">
<span class="name"><%= templates[i].display_name %></span>
</a>
</li>
<% } else { %>
<li class="editor-md">
<a href="#" data-category="<%= templates[i].category %>"
data-boilerplate="<%= templates[i].boilerplate_name %>">
<span class="name"><%= templates[i].display_name %></span>
</a>
</li>
<% } %>
<% } %>
</ul>
</div>
<a href="#" class="cancel-button"><%= gettext("Cancel") %></a>
<% } %>
<div class="new-component">
<h5><%= gettext("Add New Component") %></h5>
<ul class="new-component-type">
</ul>
</div>
...@@ -4,7 +4,6 @@ ...@@ -4,7 +4,6 @@
<header class="mast has-actions has-navigation"> <header class="mast has-actions has-navigation">
<h1 class="page-header"> <h1 class="page-header">
<small class="navigation navigation-parents"> <small class="navigation navigation-parents">
<a href="/unit/TestCourse/branch/draft/block/vertical8eb" class="navigation-link navigation-parent">Unit 1</a> <a href="/unit/TestCourse/branch/draft/block/vertical8eb" class="navigation-link navigation-parent">Unit 1</a>
<a href="#" class="navigation-link navigation-current">Nested Vertical Test</a> <a href="#" class="navigation-link navigation-current">Nested Vertical Test</a>
</small> </small>
...@@ -23,7 +22,7 @@ ...@@ -23,7 +22,7 @@
<section class="content-area"> <section class="content-area">
<article class="content-primary window"> <article class="content-primary window">
<section class="wrapper-xblock level-page" data-locator="TestCourse/branch/draft/block/vertical131"> <section class="wrapper-xblock level-page studio-xblock-wrapper" data-locator="TestCourse/branch/draft/block/vertical131">
</section> </section>
<div class="no-container-content is-hidden"> <div class="no-container-content is-hidden">
<p>This page has no content yet.</p> <p>This page has no content yet.</p>
...@@ -37,6 +36,4 @@ ...@@ -37,6 +36,4 @@
</section> </section>
</div> </div>
</div> </div>
<div id="page-notification"></div>
</div> </div>
...@@ -2,27 +2,26 @@ ...@@ -2,27 +2,26 @@
<article class="xblock-render"> <article class="xblock-render">
<div class="xblock" data-block-type="vertical" data-locator="locator-container"> <div class="xblock" data-block-type="vertical" data-locator="locator-container">
<div class="vert-mod"> <ol class="reorderable-container">
<ol class="vertical-container"> <li class="studio-xblock-wrapper is-draggable" data-locator="testCourse/branch/draft/split_test/splitFFF">
<li class="vertical-element is-draggable">
<div class="vert vert-0">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<div class="xblock" data-block-type="vertical"> <div class="xblock" data-block-type="vertical">
<div class="vert-mod"> <ol class="reorderable-container">
<ol class="vertical-container"> <li class="studio-xblock-wrapper is-draggable" data-locator="locator-group-A">
<li class="vertical-element is-draggable">
<div class="vert vert-0">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="wrapper-xblock level-nesting" data-locator="locator-group-A"> <section class="wrapper-xblock level-nesting" data-locator="locator-group-A">
<header class="xblock-header"></header> <header class="xblock-header">
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-drag">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
</li>
</ul>
</div>
</header>
<article class="xblock-render"> <article class="xblock-render">
<div class="xblock" data-block-type="vertical"> <div class="xblock" data-block-type="vertical">
<div class="vert-mod"> <ol class="reorderable-container">
<ol class="vertical-container"> <li class="studio-xblock-wrapper is-draggable" data-locator="locator-component-A1">
<li class="vertical-element is-draggable">
<div class="vert vert-0">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="wrapper-xblock level-element" <section class="wrapper-xblock level-element"
data-locator="locator-component-A1"> data-locator="locator-component-A1">
<header class="xblock-header"> <header class="xblock-header">
...@@ -40,17 +39,16 @@ ...@@ -40,17 +39,16 @@
href="#" href="#"
class="delete-button action-button"></a> class="delete-button action-button"></a>
</li> </li>
<li class="action-item action-drag">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
</li>
</ul> </ul>
</div> </div>
</header> </header>
<article class="xblock-render"></article> <article class="xblock-render"></article>
</section> </section>
</div>
</li> </li>
<li class="vertical-element is-draggable"> <li class="studio-xblock-wrapper is-draggable" data-locator="locator-component-A2">
<div class="vert vert-1">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="wrapper-xblock level-element" <section class="wrapper-xblock level-element"
data-locator="locator-component-A2"> data-locator="locator-component-A2">
...@@ -69,16 +67,16 @@ ...@@ -69,16 +67,16 @@
href="#" href="#"
class="delete-button action-button"></a> class="delete-button action-button"></a>
</li> </li>
<li class="action-item action-drag">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
</li>
</ul> </ul>
</div> </div>
</header> </header>
<article class="xblock-render"></article> <article class="xblock-render"></article>
</section> </section>
</div>
</li> </li>
<li class="vertical-element is-draggable"> <li class="studio-xblock-wrapper is-draggable" data-locator="locator-component-A3">
<div class="vert vert-2">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="wrapper-xblock level-element" <section class="wrapper-xblock level-element"
data-locator="locator-component-A3"> data-locator="locator-component-A3">
<header class="xblock-header"> <header class="xblock-header">
...@@ -96,33 +94,37 @@ ...@@ -96,33 +94,37 @@
href="#" href="#"
class="delete-button action-button"></a> class="delete-button action-button"></a>
</li> </li>
<li class="action-item action-drag">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
</li>
</ul> </ul>
</div> </div>
</header> </header>
<article class="xblock-render"></article> <article class="xblock-render"></article>
</section> </section>
</div>
</li> </li>
</ol> </ol>
</div> <div class="add-xblock-component new-component-item adding"></div>
</div> </div>
</article> </article>
</section> </section>
</div>
</li> </li>
<li class="vertical-element is-draggable"> <li class="studio-xblock-wrapper is-draggable" data-locator="locator-group-B">
<div class="vert vert-1">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="wrapper-xblock level-nesting" data-locator="locator-group-B"> <section class="wrapper-xblock level-nesting" data-locator="locator-group-B">
<header class="xblock-header"></header> <header class="xblock-header">
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-drag">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
</li>
</ul>
</div>
</header>
<article class="xblock-render"> <article class="xblock-render">
<div class="xblock" data-block-type="vertical"> <div class="xblock" data-block-type="vertical">
<div class="vert-mod"> <ol class="reorderable-container">
<ol class="vertical-container"> <li class="studio-xblock-wrapper is-draggable" data-locator="locator-component-B1">
<li class="vertical-element is-draggable">
<div class="vert vert-0">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="wrapper-xblock level-element" <section class="wrapper-xblock level-element"
data-locator="locator-component-B1"> data-locator="locator-component-B1">
...@@ -141,16 +143,16 @@ ...@@ -141,16 +143,16 @@
href="#" href="#"
class="delete-button action-button"></a> class="delete-button action-button"></a>
</li> </li>
<li class="action-item action-drag">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
</li>
</ul> </ul>
</div> </div>
</header> </header>
<article class="xblock-render"></article> <article class="xblock-render"></article>
</section> </section>
</div>
</li> </li>
<li class="vertical-element is-draggable"> <li class="studio-xblock-wrapper is-draggable" data-locator="locator-component-B2">
<div class="vert vert-1">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="wrapper-xblock level-element" <section class="wrapper-xblock level-element"
data-locator="locator-component-B2"> data-locator="locator-component-B2">
...@@ -169,16 +171,16 @@ ...@@ -169,16 +171,16 @@
href="#" href="#"
class="delete-button action-button"></a> class="delete-button action-button"></a>
</li> </li>
<li class="action-item action-drag">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
</li>
</ul> </ul>
</div> </div>
</header> </header>
<article class="xblock-render"></article> <article class="xblock-render"></article>
</section> </section>
</div>
</li> </li>
<li class="vertical-element is-draggable"> <li class="studio-xblock-wrapper is-draggable" data-locator="locator-component-B3">
<div class="vert vert-2">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="wrapper-xblock level-element" <section class="wrapper-xblock level-element"
data-locator="locator-component-B3"> data-locator="locator-component-B3">
...@@ -197,26 +199,24 @@ ...@@ -197,26 +199,24 @@
href="#" href="#"
class="delete-button action-button"></a> class="delete-button action-button"></a>
</li> </li>
<li class="action-item action-drag">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
</li>
</ul> </ul>
</div> </div>
</header> </header>
<article class="xblock-render"></article> <article class="xblock-render"></article>
</section> </section>
</div>
</li> </li>
</ol> </ol>
</div> <div class="add-xblock-component new-component-item adding"></div>
</div> </div>
</article> </article>
</section> </section>
</div>
</li> </li>
</ol> </ol>
</div> </div>
</div>
</div>
</li> </li>
</ol> </ol>
</div> </div>
</div>
</article> </article>
...@@ -14,9 +14,7 @@ ...@@ -14,9 +14,7 @@
</header> </header>
<article class="xblock-render"> <article class="xblock-render">
<div class="xblock xblock-student_view xmodule_display xmodule_VerticalModule xblock-initialized" data-runtime-class="PreviewRuntime" data-init="XBlockToXModuleShim" data-runtime-version="1" data-usage-id="i4x:;_;_AndyA;_ABT101;_vertical;_131a499ddaa3474194c1aa2eced34455" data-type="None" data-block-type="vertical"> <div class="xblock xblock-student_view xmodule_display xmodule_VerticalModule xblock-initialized" data-runtime-class="PreviewRuntime" data-init="XBlockToXModuleShim" data-runtime-version="1" data-usage-id="i4x:;_;_AndyA;_ABT101;_vertical;_131a499ddaa3474194c1aa2eced34455" data-type="None" data-block-type="vertical">
<div class="vert-mod"> <ol class="reorderable-container">
<div class="vert vert-0" data-id="i4x://AndyA/ABT101/vertical/2758bbc495dd40d59050da15b40bd9a5"> </ol>
</div>
</div>
</div> </div>
</article> </article>
<div class="wrapper wrapper-component-action-header">
<div class="component-header">Mock Component</div>
<ul class="component-actions">
<li class="action-item action-edit">
<a href="#" class="edit-button action-button">
<i class="icon-pencil"></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>
</ul>
</div>
<div class="xblock xblock-student_view" data-runtime-version="1" data-usage-id="i4x:;_;_edX;_mock"
data-init="MockXBlock" data-runtime-class="StudioRuntime" data-block-type="mock" tabindex="0">
<h2>Mock Component</h2>
</div>
<div id="content">
<div class="main-wrapper edit-state-draft" data-locator="unit_locator">
<div class="inner-wrapper">
<div class="alert editing-draft-alert">
<p class="alert-message"><strong>You are editing a draft.</strong></p>
<a href="#" target="_blank" class="alert-action secondary">View the Live Version</a>
</div>
<div class="main-column">
<article class="unit-body window">
<p class="unit-name-input"><label for="unit-display-name-input">Display Name:</label><input type="text" value="Mock Unit" id="unit-display-name-input" class="unit-display-name-input"></p>
<ol class="components ui-sortable">
<li class="component" data-locator="loc_1"></li>
<li class="component" data-locator="loc_2"></li>
<li class="add-xblock-component new-component-item adding"></li>
</ol>
</article>
</div>
<div class="sidebar">
<div class="unit-settings window">
<h4 class="header">Unit Settings</h4>
<div class="window-contents">
<div class="row visibility">
<label for="visibility-select" class="inline-label">Visibility:</label>
<select name="visibility-select" id="visibility-select" class="visibility-select">
<option value="public">Public</option>
<option value="private">Private</option>
</select>
</div>
<div class="row published-alert">
<p class="edit-draft-message">This unit has been published. To make changes, you must <a href="#" class="create-draft">edit a draft</a>.</p>
<p class="publish-draft-message">This is a draft of the published unit. To update the live version, you must <a href="#" class="publish-draft">replace it with this draft</a>.</p>
</div>
<div class="row status">
<p>
This unit is scheduled to be released to <strong>students</strong> on <strong>Jan 01, 2030 at 00:00 UTC</strong> with the subsection <a href="/subsection/AndyA.EBT1.EBT1/branch/draft/block/sequential544">Lesson 1</a>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<header class="xblock-header"> <li class="studio-xblock-wrapper is-draggable">
<header class="xblock-header">
<div class="header-details"> <div class="header-details">
<span>Mock XBlock</span> <span>Mock XBlock</span>
</div> </div>
...@@ -7,11 +8,12 @@ ...@@ -7,11 +8,12 @@
<li class="sr action-item">No Actions</li> <li class="sr action-item">No Actions</li>
</ul> </ul>
</div> </div>
</header> </header>
<article class="xblock-render"> <article class="xblock-render">
<div class="xblock xblock-student_view xmodule_display xmodule_VerticalModule" <div class="xblock xblock-student_view xmodule_display xmodule_VerticalModule"
data-runtime-class="PreviewRuntime" data-init="XBlockToXModuleShim" data-runtime-version="1" data-runtime-class="PreviewRuntime" data-init="XBlockToXModuleShim" data-runtime-version="1"
data-type="None"> data-type="None">
<div class="mock-updated-content">Mock Update</div> <div class="mock-updated-content">Mock Update</div>
</div> </div>
</article> </article>
</li>
<header class="xblock-header"> <li class="studio-xblock-wrapper is-draggable">
<header class="xblock-header">
<div class="header-details"> <div class="header-details">
<span>Mock XBlock</span> <span>Mock XBlock</span>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<ul class="actions-list"> <ul class="actions-list">
<li class="sr action-item">No Actions</li> <li class="action-item action-drag">
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
</li>
</ul> </ul>
</div> </div>
</header> </header>
<article class="xblock-render"> <article class="xblock-render">
<div class="xblock xblock-student_view xmodule_display xmodule_VerticalModule" <div class="xblock xblock-student_view xmodule_display xmodule_VerticalModule"
data-runtime-class="PreviewRuntime" data-init="XBlockToXModuleShim" data-runtime-version="1" data-runtime-class="PreviewRuntime" data-init="XBlockToXModuleShim" data-runtime-version="1"
data-type="None"> data-type="None">
<p>Mock XBlock</p> <p>Mock XBlock</p>
</div> </div>
</article> </article>
</li>
...@@ -84,7 +84,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v ...@@ -84,7 +84,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v
</div> </div>
<div class="item-actions"> <div class="item-actions">
<a href="#" data-tooltip="${_('Delete this section')}" class="delete-button delete-section-button"><span class="delete-icon"></span></a> <a href="#" data-tooltip="${_('Delete this section')}" class="delete-button delete-section-button"><span class="delete-icon"></span></a>
<span data-tooltip="${_('Drag to re-order')}" class="drag-handle"></span> <span data-tooltip="${_('Drag to re-order')}" class="drag-handle action"></span>
</div> </div>
</header> </header>
</section> </section>
...@@ -198,7 +198,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v ...@@ -198,7 +198,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v
<a href="#" data-tooltip="${_('Delete this section')}" class="action delete-section-button"><i class="icon-trash"></i> <span class="sr">${_('Delete section')}</span></a> <a href="#" data-tooltip="${_('Delete this section')}" class="action delete-section-button"><i class="icon-trash"></i> <span class="sr">${_('Delete section')}</span></a>
</li> </li>
<li class="actions-item drag"> <li class="actions-item drag">
<span data-tooltip="${_('Drag to reorder')}" class="drag-handle section-drag-handle action"><span class="sr"> ${_("Drag to reorder section")}</span></span> <span data-tooltip="${_('Drag to reorder')}" class="drag-handle section-drag-handle"><span class="sr"> ${_("Drag to reorder section")}</span></span>
</li> </li>
</ul> </ul>
</div> </div>
...@@ -235,7 +235,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v ...@@ -235,7 +235,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v
<a href="#" data-tooltip="${_('Delete this subsection')}" class="action delete-subsection-button"><i class="icon-trash"></i> <span class="sr">${_("Delete subsection")}</span></a> <a href="#" data-tooltip="${_('Delete this subsection')}" class="action delete-subsection-button"><i class="icon-trash"></i> <span class="sr">${_("Delete subsection")}</span></a>
</li> </li>
<li class="actions-item drag"> <li class="actions-item drag">
<span data-tooltip="${_('Drag to reorder')}" class="drag-handle subsection-drag-handle action"></span> <span data-tooltip="${_('Drag to reorder')}" class="drag-handle subsection-drag-handle"></span>
</li> </li>
</ul> </ul>
</div> </div>
......
<%!
from django.utils.translation import ugettext as _
from contentstore.views.helpers import xblock_studio_url
%>
<%namespace name='static' file='static_content.html'/>
% if is_reorderable:
<li class="studio-xblock-wrapper is-draggable" data-locator="${locator}">
% else:
<div class="studio-xblock-wrapper">
% endif
<section class="wrapper-xblock xblock-type-container level-element" data-locator="${locator}">
<header class="xblock-header">
<div class="header-details">
${xblock.display_name_with_default}
</div>
<div class="header-actions">
<ul class="actions-list">
<li class="action-item action-view">
<a href="${xblock_studio_url(xblock)}" 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>
% if not xblock_context['read_only'] and is_reorderable:
<li class="action-item action-drag">
<span data-tooltip="${_('Drag to reorder')}" class="drag-handle action"></span>
</li>
% endif
</ul>
</div>
</header>
</section>
% if is_reorderable:
</li>
% else:
</div>
% endif
<%! 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_with_default | 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 _ %> <%! from django.utils.translation import ugettext as _ %>
<%! from django.conf import settings %>
% if xblock.location != xblock_context['root_xblock'].location: % if not is_root:
<% section_class = "level-nesting" if xblock.has_children else "level-element" %> % if is_reorderable:
<section class="wrapper-xblock ${section_class}" data-locator="${locator}" data-display-name="${xblock.display_name_with_default | h}" data-category="${xblock.category | h}"> <li class="studio-xblock-wrapper is-draggable" data-locator="${locator}">
% else:
<div class="studio-xblock-wrapper" data-locator="${locator}">
% endif
<%
section_class = "level-nesting" if xblock.has_children else "level-element"
collapsible_class = "is-collapsible" if xblock.has_children else ""
%>
<section class="wrapper-xblock ${section_class} ${collapsible_class}">
% endif % endif
<header class="xblock-header"> <header class="xblock-header">
<div class="header-details"> <div class="header-details">
${xblock.display_name_with_default | h} % if xblock.has_children:
<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>
% endif
<span>${xblock.display_name_with_default | h}</span>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<ul class="actions-list"> <ul class="actions-list">
% if not xblock_context['read_only']: % if not xblock_context['read_only']:
% if not xblock.has_children:
<li class="action-item action-edit"> <li class="action-item action-edit">
<a href="#" class="edit-button action-button"> <a href="#" class="edit-button action-button">
<i class="icon-pencil"></i> <i class="icon-pencil"></i>
<span class="action-button-text">${_("Edit")}</span> <span class="action-button-text">${_("Edit")}</span>
</a> </a>
</li> </li>
% endif
%if settings.FEATURES.get('ENABLE_DUPLICATE_XBLOCK_LEAF_COMPONENT'):
<li class="action-item action-duplicate"> <li class="action-item action-duplicate">
<a href="#" data-tooltip="${_("Duplicate")}" class="duplicate-button action-button"> <a href="#" data-tooltip="${_("Duplicate")}" class="duplicate-button action-button">
<i class="icon-copy"></i> <i class="icon-copy"></i>
<span class="sr">${_("Duplicate")}</span> <span class="sr">${_("Duplicate")}</span>
</a> </a>
</li> </li>
% endif
%if settings.FEATURES.get('ENABLE_DELETE_XBLOCK_LEAF_COMPONENT'):
<li class="action-item action-delete"> <li class="action-item action-delete">
<a href="#" data-tooltip="${_("Delete")}" class="delete-button action-button"> <a href="#" data-tooltip="${_("Delete")}" class="delete-button action-button">
<i class="icon-trash"></i> <i class="icon-trash"></i>
...@@ -36,6 +47,12 @@ ...@@ -36,6 +47,12 @@
</a> </a>
</li> </li>
% endif % endif
% if not is_root and is_reorderable:
<li class="action-item action-drag">
<span data-tooltip="${_('Drag to reorder')}" class="drag-handle action"></span>
</li>
% endif
% endif
</ul> </ul>
</div> </div>
</header> </header>
...@@ -43,6 +60,11 @@ ...@@ -43,6 +60,11 @@
${content} ${content}
</article> </article>
% if xblock.location != xblock_context['root_xblock'].location: % if not is_root:
</section> </section>
% if is_reorderable:
</li>
% else:
</div>
% endif
% endif % endif
...@@ -21,13 +21,12 @@ from xmodule.modulestore.django import loc_mapper ...@@ -21,13 +21,12 @@ from xmodule.modulestore.django import loc_mapper
<%block name="jsextra"> <%block name="jsextra">
<script type='text/javascript'> <script type='text/javascript'>
require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit", "jquery.ui", require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit", "js/collections/component_template",
"xmodule", "coffee/src/main", "xblock/cms.runtime.v1"], "jquery.ui", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
function(doc, $, ModuleModel, UnitEditView, ui) { function(doc, $, ModuleModel, UnitEditView, ComponentTemplates) {
window.unit_location_analytics = '${unit_locator}'; window.unit_location_analytics = '${unit_locator}';
// tabs var templates = new ComponentTemplates(${component_templates | n}, {parse: true});
$('.tab-group').tabs();
new UnitEditView({ new UnitEditView({
el: $('.main-wrapper'), el: $('.main-wrapper'),
...@@ -35,7 +34,8 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit" ...@@ -35,7 +34,8 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
model: new ModuleModel({ model: new ModuleModel({
id: '${unit_locator}', id: '${unit_locator}',
state: '${unit_state}' state: '${unit_state}'
}) }),
templates: templates
}); });
$('.new-component-template').each(function(){ $('.new-component-template').each(function(){
...@@ -64,87 +64,8 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit" ...@@ -64,87 +64,8 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
% for locator in locators: % for locator in locators:
<li class="component" data-locator="${locator}"/> <li class="component" data-locator="${locator}"/>
% endfor % endfor
<li class="new-component-item adding">
<div class="new-component">
<h5>${_("Add New Component")}</h5>
<ul class="new-component-type">
% for type, templates in sorted(component_templates.items()):
<li>
% if type == 'advanced' or len(templates) > 1:
<a href="#" class="multiple-templates" data-type="${type}">
% else:
% for __, category, __, __ in templates:
<a href="#" class="single-template" data-type="${type}" data-category="${category}">
% endfor
% endif
<span class="large-template-icon large-${type}-icon"></span>
<span class="name">${type}</span>
</a>
</li>
% endfor
</ul>
</div>
% for type, templates in sorted(component_templates.items()):
% if len(templates) > 1 or type == 'advanced':
<div class="new-component-templates new-component-${type}">
% if type == "problem":
<div class="tab-group tabs">
<ul class="problem-type-tabs nav-tabs">
<li class="current">
<a class="link-tab" href="#tab1">${_("Common Problem Types")}</a>
</li>
<li>
<a class="link-tab" href="#tab2">${_("Advanced")}</a>
</li>
</ul>
% endif
<div class="tab current" id="tab1">
<ul class="new-component-template">
% for name, category, has_markdown, boilerplate_name in sorted(templates):
% if has_markdown or type != "problem":
% if boilerplate_name is None:
<li class="editor-md empty">
<a href="#" data-category="${category}">
<span class="name">${name}</span>
</a>
</li>
% else:
<li class="editor-md">
<a href="#" data-category="${category}"
data-boilerplate="${boilerplate_name}">
<span class="name">${name}</span>
</a>
</li>
% endif
% endif
%endfor
</ul>
</div>
% if type == "problem":
<div class="tab" id="tab2">
<ul class="new-component-template">
% for name, category, has_markdown, boilerplate_name in sorted(templates):
% if not has_markdown:
<li class="editor-manual">
<a href="#" data-category="${category}"
data-boilerplate="${boilerplate_name}">
<span class="name">${name}</span>
</a>
</li>
% endif
% endfor
</ul>
</div>
</div>
% endif
<a href="#" class="cancel-button">Cancel</a>
</div>
% endif
% endfor
</li>
</ol> </ol>
<div class="add-xblock-component new-component-item adding"></div>
</article> </article>
</div> </div>
......
...@@ -127,7 +127,7 @@ from django.utils.translation import ugettext as _ ...@@ -127,7 +127,7 @@ from django.utils.translation import ugettext as _
</li> </li>
</ul> </ul>
</div> </div>
<span data-tooltip="Drag to reorder" class="drag-handle"></span> <span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="xblock xblock-student_view xmodule_display xmodule_HtmlModule" data-runtime-version="1" data-init="XBlockToXModuleShim" data-handler-prefix="/preview/xblock/i4x:;_;_andya;_AA101;_html;_c8fb4780eb554aec95c6231680eb82cf/handler" data-type="HTMLModule" data-block-type="html"> <section class="xblock xblock-student_view xmodule_display xmodule_HtmlModule" data-runtime-version="1" data-init="XBlockToXModuleShim" data-handler-prefix="/preview/xblock/i4x:;_;_andya;_AA101;_html;_c8fb4780eb554aec95c6231680eb82cf/handler" data-type="HTMLModule" data-block-type="html">
<ol> <ol>
<li> <li>
...@@ -306,7 +306,7 @@ from django.utils.translation import ugettext as _ ...@@ -306,7 +306,7 @@ from django.utils.translation import ugettext as _
</li> </li>
</ul> </ul>
</div> </div>
<span data-tooltip="Drag to reorder" class="drag-handle"></span> <span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="xblock xblock-student_view xmodule_display xmodule_VideoModule" data-runtime-version="1" data-init="XBlockToXModuleShim" data-handler-prefix="/preview/xblock/i4x:;_;_andya;_AA101;_video;_da30d8c1da6d43268152e19089ecc2fa/handler" data-type="Video" data-block-type="video"> <section class="xblock xblock-student_view xmodule_display xmodule_VideoModule" data-runtime-version="1" data-init="XBlockToXModuleShim" data-handler-prefix="/preview/xblock/i4x:;_;_andya;_AA101;_video;_da30d8c1da6d43268152e19089ecc2fa/handler" data-type="Video" data-block-type="video">
...@@ -561,7 +561,7 @@ from django.utils.translation import ugettext as _ ...@@ -561,7 +561,7 @@ from django.utils.translation import ugettext as _
</li> </li>
</ul> </ul>
</div> </div>
<span data-tooltip="Drag to reorder" class="drag-handle"></span> <span data-tooltip="Drag to reorder" class="drag-handle action"></span>
<section class="xblock xblock-student_view xmodule_display xmodule_CapaModule" data-runtime-version="1" data-init="XBlockToXModuleShim" data-handler-prefix="/preview/xblock/i4x:;_;_andya;_AA101;_problem;_2fa3ab8048514b73b36e8807a42b3525/handler" data-type="Problem" data-block-type="problem"> <section class="xblock xblock-student_view xmodule_display xmodule_CapaModule" data-runtime-version="1" data-init="XBlockToXModuleShim" data-handler-prefix="/preview/xblock/i4x:;_;_andya;_AA101;_problem;_2fa3ab8048514b73b36e8807a42b3525/handler" data-type="Problem" data-block-type="problem">
<section id="problem_i4x-andya-AA101-problem-2fa3ab8048514b73b36e8807a42b3525" class="problems-wrapper" data-problem-id="i4x://andya/AA101/problem/2fa3ab8048514b73b36e8807a42b3525" data-url="/preview/xblock/i4x:;_;_andya;_AA101;_problem;_2fa3ab8048514b73b36e8807a42b3525/handler/xmodule_handler" data-progress_status="0" data-progress_detail="0"> <section id="problem_i4x-andya-AA101-problem-2fa3ab8048514b73b36e8807a42b3525" class="problems-wrapper" data-problem-id="i4x://andya/AA101/problem/2fa3ab8048514b73b36e8807a42b3525" data-url="/preview/xblock/i4x:;_;_andya;_AA101;_problem;_2fa3ab8048514b73b36e8807a42b3525/handler/xmodule_handler" data-progress_status="0" data-progress_detail="0">
......
...@@ -42,7 +42,7 @@ This def will enumerate through a passed in subsection and list all of the units ...@@ -42,7 +42,7 @@ This def will enumerate through a passed in subsection and list all of the units
<a href="#" data-tooltip="${_("Delete this unit")}" class="delete-unit-button action" data-locator="${unit_locator}"><i class="icon-trash"></i><span class="sr">${_("Delete unit")}</span></a> <a href="#" data-tooltip="${_("Delete this unit")}" class="delete-unit-button action" data-locator="${unit_locator}"><i class="icon-trash"></i><span class="sr">${_("Delete unit")}</span></a>
</li> </li>
<li class="actions-item drag"> <li class="actions-item drag">
<span data-tooltip="${_("Drag to sort")}" class="drag-handle unit-drag-handle action"><span class="sr"> ${_("Drag to reorder unit")}</span></span> <span data-tooltip="${_("Drag to sort")}" class="drag-handle unit-drag-handle"><span class="sr"> ${_("Drag to reorder unit")}</span></span>
</li> </li>
</ul> </ul>
</div> </div>
...@@ -61,6 +61,3 @@ This def will enumerate through a passed in subsection and list all of the units ...@@ -61,6 +61,3 @@ This def will enumerate through a passed in subsection and list all of the units
</li> </li>
</ol> </ol>
</%def> </%def>
"""
Mixin to support editing in Studio.
"""
class StudioEditableModule(object):
"""
Helper methods for supporting Studio editing of xblocks.
"""
def render_reorderable_children(self, context, fragment):
"""
Renders children with the appropriate HTML structure for drag and drop.
"""
contents = []
for child in self.get_display_items():
context['reorderable_items'].add(child.location)
rendered_child = child.render('student_view', context)
fragment.add_frag_resources(rendered_child)
contents.append({
'id': child.id,
'content': rendered_child.content
})
fragment.add_content(self.system.render_template("studio_render_children_view.html", {
'items': contents,
'xblock_context': context,
}))
"""
Tests for StudioEditableModule.
"""
from xmodule.tests.test_vertical import BaseVerticalModuleTest
class StudioEditableModuleTestCase(BaseVerticalModuleTest):
def test_render_reorderable_children(self):
"""
Test the behavior of render_reorderable_children.
"""
reorderable_items = set()
context = {
'runtime_type': 'studio',
'container_view': True,
'reorderable_items': reorderable_items,
'read_only': False,
'root_xblock': self.vertical,
}
# Both children of the vertical should be rendered as reorderable
self.module_system.render(self.vertical, 'student_view', context).content
self.assertIn(self.vertical.get_children()[0].location, reorderable_items)
self.assertIn(self.vertical.get_children()[1].location, reorderable_items)
"""
Tests for vertical module.
"""
from fs.memoryfs import MemoryFS
from xmodule.tests import get_test_system
from xmodule.tests.xml import XModuleXmlImportTest
from xmodule.tests.xml import factories as xml
class BaseVerticalModuleTest(XModuleXmlImportTest):
test_html_1 = 'Test HTML 1'
test_html_2 = 'Test HTML 2'
def setUp(self):
self.course_id = 'test_org/test_course_number/test_run'
# construct module
course = xml.CourseFactory.build()
sequence = xml.SequenceFactory.build(parent=course)
vertical = xml.VerticalFactory.build(parent=sequence)
self.course = self.process_xml(course)
xml.HtmlFactory(parent=vertical, url_name='test-html-1', text=self.test_html_1)
xml.HtmlFactory(parent=vertical, url_name='test-html-2', text=self.test_html_2)
self.course = self.process_xml(course)
course_seq = self.course.get_children()[0]
self.module_system = get_test_system()
def get_module(descriptor):
"""Mocks module_system get_module function"""
module_system = get_test_system()
module_system.get_module = get_module
descriptor.bind_for_student(module_system, descriptor._field_data) # pylint: disable=protected-access
return descriptor
self.module_system.get_module = get_module
self.module_system.descriptor_system = self.course.runtime
self.course.runtime.export_fs = MemoryFS()
self.vertical = course_seq.get_children()[0]
self.vertical.xmodule_runtime = self.module_system
class VerticalModuleTestCase(BaseVerticalModuleTest):
def test_render_student_view(self):
"""
Test the rendering of the student view.
"""
html = self.module_system.render(self.vertical, 'student_view', {}).content
self.assertIn(self.test_html_1, html)
self.assertIn(self.test_html_2, html)
def test_render_studio_view(self):
"""
Test the rendering of the Studio view
"""
reorderable_items = set()
context = {
'runtime_type': 'studio',
'reorderable_items': reorderable_items,
}
html = self.module_system.render(self.vertical, 'student_view', context).content
self.assertIn(self.test_html_1, html)
self.assertIn(self.test_html_2, html)
...@@ -2,6 +2,7 @@ from xblock.fragment import Fragment ...@@ -2,6 +2,7 @@ from xblock.fragment import Fragment
from xmodule.x_module import XModule from xmodule.x_module import XModule
from xmodule.seq_module import SequenceDescriptor from xmodule.seq_module import SequenceDescriptor
from xmodule.progress import Progress from xmodule.progress import Progress
from xmodule.studio_editable import StudioEditableModule
from pkg_resources import resource_string from pkg_resources import resource_string
from copy import copy from copy import copy
...@@ -14,7 +15,7 @@ class VerticalFields(object): ...@@ -14,7 +15,7 @@ class VerticalFields(object):
has_children = True has_children = True
class VerticalModule(VerticalFields, XModule): class VerticalModule(VerticalFields, XModule, StudioEditableModule):
''' Layout module for laying out submodules vertically.''' ''' Layout module for laying out submodules vertically.'''
def student_view(self, context): def student_view(self, context):
...@@ -28,7 +29,9 @@ class VerticalModule(VerticalFields, XModule): ...@@ -28,7 +29,9 @@ class VerticalModule(VerticalFields, XModule):
""" """
Renders the Studio preview view, which supports drag and drop. Renders the Studio preview view, which supports drag and drop.
""" """
return self.render_view(context, 'vert_module_studio_view.html') fragment = Fragment()
self.render_reorderable_children(context, fragment)
return fragment
def render_view(self, context, template_name): def render_view(self, context, template_name):
""" """
......
...@@ -8,6 +8,9 @@ from . import BASE_URL ...@@ -8,6 +8,9 @@ from . import BASE_URL
from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.action_chains import ActionChains
from utils import click_css, wait_for_notification
class ContainerPage(PageObject): class ContainerPage(PageObject):
""" """
Container page in Studio Container page in Studio
...@@ -45,23 +48,49 @@ class ContainerPage(PageObject): ...@@ -45,23 +48,49 @@ class ContainerPage(PageObject):
return self.q(css=XBlockWrapper.BODY_SELECTOR).map( return self.q(css=XBlockWrapper.BODY_SELECTOR).map(
lambda el: XBlockWrapper(self.browser, el.get_attribute('data-locator'))).results lambda el: XBlockWrapper(self.browser, el.get_attribute('data-locator'))).results
def drag(self, source_index, target_index, after=True): def drag(self, source_index, target_index):
""" """
Gets the drag handle with index source_index (relative to the vertical layout of the page) Gets the drag handle with index source_index (relative to the vertical layout of the page)
and drags it to the location of the drag handle with target_index. and drags it to the location of the drag handle with target_index.
This should drag the element with the source_index drag handle AFTER the This should drag the element with the source_index drag handle BEFORE the
one with the target_index drag handle, unless 'after' is set to False. one with the target_index drag handle.
""" """
draggables = self.q(css='.drag-handle') draggables = self.q(css='.drag-handle')
source = draggables[source_index] source = draggables[source_index]
target = draggables[target_index] target = draggables[target_index]
action = ActionChains(self.browser) action = ActionChains(self.browser)
action.click_and_hold(source).perform() # pylint: disable=protected-access # When dragging before the target element, must take into account that the placeholder
action.move_to_element_with_offset( # will appear in the place where the target used to be.
target, 0, target.size['height'] / 2 if after else 0 placeholder_height = 40
).perform() # pylint: disable=protected-access action.click_and_hold(source).move_to_element_with_offset(
action.release().perform() target, 0, placeholder_height
).release().perform()
wait_for_notification(self)
def add_discussion(self, menu_index):
"""
Add a new instance of the discussion category.
menu_index specifies which instance of the menus should be used (based on vertical
placement within the page).
"""
click_css(self, 'a>span.large-discussion-icon', menu_index)
def duplicate(self, source_index):
"""
Duplicate the item with index source_index (based on vertical placement in page).
"""
click_css(self, 'a.duplicate-button', source_index)
def delete(self, source_index):
"""
Delete the item with index source_index (based on vertical placement in page).
"""
click_css(self, 'a.delete-button', source_index, require_notification=False)
# Click the confirmation dialog button
click_css(self, 'a.button.action-primary', 0)
class XBlockWrapper(PageObject): class XBlockWrapper(PageObject):
...@@ -69,7 +98,7 @@ class XBlockWrapper(PageObject): ...@@ -69,7 +98,7 @@ class XBlockWrapper(PageObject):
A PageObject representing a wrapper around an XBlock child shown on the Studio container page. A PageObject representing a wrapper around an XBlock child shown on the Studio container page.
""" """
url = None url = None
BODY_SELECTOR = '.wrapper-xblock' BODY_SELECTOR = '.studio-xblock-wrapper'
NAME_SELECTOR = '.header-details' NAME_SELECTOR = '.header-details'
def __init__(self, browser, locator): def __init__(self, browser, locator):
......
"""
Utility methods useful for Studio page tests.
"""
from bok_choy.promise import Promise
from selenium.webdriver.common.action_chains import ActionChains
def click_css(page, css, source_index, require_notification=True):
"""
Click the button/link with the given css and index on the specified page (subclass of PageObject).
If require_notification is False (default value is True), the method will return immediately.
Otherwise, it will wait for the "mini-notification" to appear and disappear.
"""
buttons = page.q(css=css)
target = buttons[source_index]
ActionChains(page.browser).click(target).release().perform()
if require_notification:
wait_for_notification(page)
def wait_for_notification(page):
"""
Waits for the "mini-notification" to appear and disappear on the given page (subclass of PageObject).
"""
def _is_saving():
num_notifications = len(page.q(css='.wrapper-notification-mini.is-shown'))
return (num_notifications == 1, num_notifications)
def _is_saving_done():
num_notifications = len(page.q(css='.wrapper-notification-mini.is-hiding'))
return (num_notifications == 1, num_notifications)
Promise(_is_saving, 'Notification showing.').fulfill()
Promise(_is_saving_done, 'Notification hidden.').fulfill()
...@@ -38,6 +38,20 @@ class ContainerBase(UniqueCourseTest): ...@@ -38,6 +38,20 @@ class ContainerBase(UniqueCourseTest):
self.group_b_item_1 = "Group B Item 1" self.group_b_item_1 = "Group B Item 1"
self.group_b_item_2 = "Group B Item 2" self.group_b_item_2 = "Group B Item 2"
self.group_a_handle = 0
self.group_a_item_1_handle = 1
self.group_a_item_2_handle = 2
self.group_empty_handle = 3
self.group_b_handle = 4
self.group_b_item_1_handle = 5
self.group_b_item_2_handle = 6
self.group_a_item_1_action_index = 0
self.group_a_item_2_action_index = 1
self.duplicate_label = "Duplicate of '{0}'"
self.discussion_label = "Discussion"
self.setup_fixtures() self.setup_fixtures()
self.auth_page.visit() self.auth_page.visit()
...@@ -79,13 +93,6 @@ class ContainerBase(UniqueCourseTest): ...@@ -79,13 +93,6 @@ class ContainerBase(UniqueCourseTest):
container = unit.components[0].go_to_container() container = unit.components[0].go_to_container()
return container return container
class DragAndDropTest(ContainerBase):
"""
Tests of reordering within the container page.
"""
__test__ = True
def verify_ordering(self, container, expected_orderings): def verify_ordering(self, container, expected_orderings):
xblocks = container.xblocks xblocks = container.xblocks
for expected_ordering in expected_orderings: for expected_ordering in expected_orderings:
...@@ -101,25 +108,38 @@ class DragAndDropTest(ContainerBase): ...@@ -101,25 +108,38 @@ class DragAndDropTest(ContainerBase):
self.assertEqual(expected, children[idx].name) self.assertEqual(expected, children[idx].name)
break break
def drag_and_verify(self, source, target, expected_ordering, after=True): def do_action_and_verify(self, action, expected_ordering):
container = self.go_to_container_page(make_draft=True) container = self.go_to_container_page(make_draft=True)
container.drag(source, target, after) action(container)
self.verify_ordering(container, expected_ordering) self.verify_ordering(container, expected_ordering)
# Reload the page to see that the reordering was saved persisted. # Reload the page to see that the change was persisted.
container = self.go_to_container_page() container = self.go_to_container_page()
self.verify_ordering(container, expected_ordering) self.verify_ordering(container, expected_ordering)
class DragAndDropTest(ContainerBase):
"""
Tests of reordering within the container page.
"""
__test__ = True
def drag_and_verify(self, source, target, expected_ordering):
self.do_action_and_verify(
lambda (container): container.drag(source, target),
expected_ordering
)
def test_reorder_in_group(self): def test_reorder_in_group(self):
""" """
Drag Group B Item 2 before Group B Item 1. Drag Group A Item 2 before Group A Item 1.
""" """
expected_ordering = [{self.container_title: [self.group_a, self.group_empty, self.group_b]}, expected_ordering = [{self.container_title: [self.group_a, self.group_empty, self.group_b]},
{self.group_a: [self.group_a_item_1, self.group_a_item_2]}, {self.group_a: [self.group_a_item_2, self.group_a_item_1]},
{self.group_b: [self.group_b_item_2, self.group_b_item_1]}, {self.group_b: [self.group_b_item_1, self.group_b_item_2]},
{self.group_empty: []}] {self.group_empty: []}]
self.drag_and_verify(6, 4, expected_ordering) self.drag_and_verify(self.group_a_item_2_handle, self.group_a_item_1_handle, expected_ordering)
def test_drag_to_top(self): def test_drag_to_top(self):
""" """
...@@ -129,35 +149,157 @@ class DragAndDropTest(ContainerBase): ...@@ -129,35 +149,157 @@ class DragAndDropTest(ContainerBase):
{self.group_a: [self.group_a_item_2]}, {self.group_a: [self.group_a_item_2]},
{self.group_b: [self.group_b_item_1, self.group_b_item_2]}, {self.group_b: [self.group_b_item_1, self.group_b_item_2]},
{self.group_empty: []}] {self.group_empty: []}]
self.drag_and_verify(1, 0, expected_ordering, False) self.drag_and_verify(self.group_a_item_1_handle, self.group_a_handle, expected_ordering)
def test_drag_into_different_group(self): def test_drag_into_different_group(self):
""" """
Drag Group A Item 1 into Group B (last element). Drag Group B Item 1 into Group A (first element).
""" """
expected_ordering = [{self.container_title: [self.group_a, self.group_empty, self.group_b]}, expected_ordering = [{self.container_title: [self.group_a, self.group_empty, self.group_b]},
{self.group_a: [self.group_a_item_2]}, {self.group_a: [self.group_b_item_1, self.group_a_item_1, self.group_a_item_2]},
{self.group_b: [self.group_b_item_1, self.group_b_item_2, self.group_a_item_1]}, {self.group_b: [self.group_b_item_2]},
{self.group_empty: []}] {self.group_empty: []}]
self.drag_and_verify(1, 6, expected_ordering) self.drag_and_verify(self.group_b_item_1_handle, self.group_a_item_1_handle, expected_ordering)
def test_drag_group_into_group(self): def test_drag_group_into_group(self):
""" """
Drag Group B into Group A (last element). Drag Group B into Group A (first element).
""" """
expected_ordering = [{self.container_title: [self.group_a, self.group_empty]}, expected_ordering = [{self.container_title: [self.group_a, self.group_empty]},
{self.group_a: [self.group_a_item_1, self.group_a_item_2, self.group_b]}, {self.group_a: [self.group_b, self.group_a_item_1, self.group_a_item_2]},
{self.group_b: [self.group_b_item_1, self.group_b_item_2]},
{self.group_empty: []}]
self.drag_and_verify(self.group_b_handle, self.group_a_item_1_handle, expected_ordering)
def test_drag_after_addition(self):
"""
Add some components and then verify that drag and drop still works.
"""
group_a_menu = 0
def add_new_components_and_rearrange(container):
# Add a video component to Group 1
container.add_discussion(group_a_menu)
# Duplicate the first item in Group A
container.duplicate(self.group_a_item_1_action_index)
first_handle = self.group_a_item_1_handle
# Drag newly added video component to top.
container.drag(first_handle + 3, first_handle)
# Drag duplicated component to top.
container.drag(first_handle + 2, first_handle)
duplicate_label = self.duplicate_label.format(self.group_a_item_1)
expected_ordering = [{self.container_title: [self.group_a, self.group_empty, self.group_b]},
{self.group_a: [duplicate_label, self.discussion_label, self.group_a_item_1, self.group_a_item_2]},
{self.group_b: [self.group_b_item_1, self.group_b_item_2]},
{self.group_empty: []}]
self.do_action_and_verify(add_new_components_and_rearrange, expected_ordering)
class AddComponentTest(ContainerBase):
"""
Tests of adding a component to the container page.
"""
__test__ = True
def add_and_verify(self, menu_index, expected_ordering):
self.do_action_and_verify(
lambda (container): container.add_discussion(menu_index),
expected_ordering
)
def test_add_component_in_group(self):
group_b_menu = 2
expected_ordering = [{self.container_title: [self.group_a, self.group_empty, self.group_b]},
{self.group_a: [self.group_a_item_1, self.group_a_item_2]},
{self.group_b: [self.group_b_item_1, self.group_b_item_2, self.discussion_label]},
{self.group_empty: []}]
self.add_and_verify(group_b_menu, expected_ordering)
def test_add_component_in_empty_group(self):
group_empty_menu = 1
expected_ordering = [{self.container_title: [self.group_a, self.group_empty, self.group_b]},
{self.group_a: [self.group_a_item_1, self.group_a_item_2]},
{self.group_b: [self.group_b_item_1, self.group_b_item_2]},
{self.group_empty: [self.discussion_label]}]
self.add_and_verify(group_empty_menu, expected_ordering)
def test_add_component_in_container(self):
container_menu = 3
expected_ordering = [{self.container_title: [self.group_a, self.group_empty, self.group_b, self.discussion_label]},
{self.group_a: [self.group_a_item_1, self.group_a_item_2]},
{self.group_b: [self.group_b_item_1, self.group_b_item_2]},
{self.group_empty: []}]
self.add_and_verify(container_menu, expected_ordering)
class DuplicateComponentTest(ContainerBase):
"""
Tests of duplicating a component on the container page.
"""
__test__ = True
def duplicate_and_verify(self, source_index, expected_ordering):
self.do_action_and_verify(
lambda (container): container.duplicate(source_index),
expected_ordering
)
def test_duplicate_first_in_group(self):
duplicate_label = self.duplicate_label.format(self.group_a_item_1)
expected_ordering = [{self.container_title: [self.group_a, self.group_empty, self.group_b]},
{self.group_a: [self.group_a_item_1, duplicate_label, self.group_a_item_2]},
{self.group_b: [self.group_b_item_1, self.group_b_item_2]},
{self.group_empty: []}]
self.duplicate_and_verify(self.group_a_item_1_action_index, expected_ordering)
def test_duplicate_second_in_group(self):
duplicate_label = self.duplicate_label.format(self.group_a_item_2)
expected_ordering = [{self.container_title: [self.group_a, self.group_empty, self.group_b]},
{self.group_a: [self.group_a_item_1, self.group_a_item_2, duplicate_label]},
{self.group_b: [self.group_b_item_1, self.group_b_item_2]},
{self.group_empty: []}]
self.duplicate_and_verify(self.group_a_item_2_action_index, expected_ordering)
def test_duplicate_the_duplicate(self):
first_duplicate_label = self.duplicate_label.format(self.group_a_item_1)
second_duplicate_label = self.duplicate_label.format(first_duplicate_label)
expected_ordering = [
{self.container_title: [self.group_a, self.group_empty, self.group_b]},
{self.group_a: [self.group_a_item_1, first_duplicate_label, second_duplicate_label, self.group_a_item_2]},
{self.group_b: [self.group_b_item_1, self.group_b_item_2]},
{self.group_empty: []}
]
def duplicate_twice(container):
container.duplicate(self.group_a_item_1_action_index)
container.duplicate(self.group_a_item_1_action_index + 1)
self.do_action_and_verify(duplicate_twice, expected_ordering)
class DeleteComponentTest(ContainerBase):
"""
Tests of deleting a component from the container page.
"""
__test__ = True
def delete_and_verify(self, source_index, expected_ordering):
self.do_action_and_verify(
lambda (container): container.delete(source_index),
expected_ordering
)
def test_delete_first_in_group(self):
expected_ordering = [{self.container_title: [self.group_a, self.group_empty, self.group_b]},
{self.group_a: [self.group_a_item_2]},
{self.group_b: [self.group_b_item_1, self.group_b_item_2]}, {self.group_b: [self.group_b_item_1, self.group_b_item_2]},
{self.group_empty: []}] {self.group_empty: []}]
self.drag_and_verify(4, 2, expected_ordering) self.delete_and_verify(self.group_a_item_1_action_index, expected_ordering)
# Not able to drag into the empty group with automation (difficult even outside of automation).
# def test_drag_into_empty(self):
# """
# Drag Group B Item 1 to Group Empty.
# """
# expected_ordering = [{self.container_title: [self.group_a, self.group_empty, self.group_b]},
# {self.group_a: [self.group_a_item_1, self.group_a_item_2]},
# {self.group_b: [self.group_b_item_2]},
# {self.group_empty: [self.group_b_item_1]}]
# self.drag_and_verify(6, 4, expected_ordering, False)
<ol class="reorderable-container">
% for item in items:
${item['content']}
% endfor
</ol>
% if not xblock_context['read_only']:
<div class="add-xblock-component new-component-item adding"></div>
% endif
<%!
from django.utils.translation import ugettext as _
%>
<div class="vert-mod">
<ol class="vertical-container">
% for idx, item in enumerate(items):
<li class="vertical-element is-draggable">
<div class="vert vert-${idx}" data-id="${item['id']}">
% if not xblock_context['read_only']:
<span data-tooltip="${_('Drag to reorder')}" class="drag-handle action"></span>
% endif
${item['content']}
</div>
</li>
% endfor
</ol>
</div>
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