Merge pull request #3579 from edx/andya/container-add-buttons
Allow creation of components on container page
This commit is contained in:
@@ -7,6 +7,8 @@ the top. Include a label indicating the component affected.
|
||||
|
||||
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.
|
||||
|
||||
Common: Add extensible third-party auth module.
|
||||
@@ -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
|
||||
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
|
||||
download a handout associated with a video (of arbitrary file format). BLD-1000.
|
||||
|
||||
@@ -490,12 +490,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
Tests the ajax callback to render an XModule
|
||||
"""
|
||||
resp = self._test_preview(Location('i4x', 'edX', 'toy', 'vertical', 'vertical_test', None), 'container_preview')
|
||||
# These are the data-ids of the xblocks contained in the vertical.
|
||||
# Ultimately, these must be converted to new locators.
|
||||
self.assertContains(resp, 'i4x://edX/toy/video/sample_video')
|
||||
self.assertContains(resp, 'i4x://edX/toy/video/separate_file_video')
|
||||
self.assertContains(resp, 'i4x://edX/toy/video/video_with_end_time')
|
||||
self.assertContains(resp, 'i4x://edX/toy/poll_question/T1_changemind_poll_foo_2')
|
||||
self.assertContains(resp, '/branch/draft/block/sample_video')
|
||||
self.assertContains(resp, '/branch/draft/block/separate_file_video')
|
||||
self.assertContains(resp, '/branch/draft/block/video_with_end_time')
|
||||
self.assertContains(resp, '/branch/draft/block/T1_changemind_poll_foo_2')
|
||||
|
||||
def _test_preview(self, location, view_name):
|
||||
""" Preview test case. """
|
||||
|
||||
@@ -164,70 +164,7 @@ def unit_handler(request, tag=None, package_id=None, branch=None, version_guid=N
|
||||
except ItemNotFoundError:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
component_templates = defaultdict(list)
|
||||
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
|
||||
)
|
||||
component_templates = _get_component_templates(course)
|
||||
|
||||
xblocks = item.get_children()
|
||||
locators = [
|
||||
@@ -274,7 +211,7 @@ def unit_handler(request, tag=None, package_id=None, branch=None, version_guid=N
|
||||
'unit': item,
|
||||
'unit_locator': locator,
|
||||
'locators': locators,
|
||||
'component_templates': component_templates,
|
||||
'component_templates': json.dumps(component_templates),
|
||||
'draft_preview_link': preview_lms_link,
|
||||
'published_preview_link': lms_link,
|
||||
'subsection': containing_subsection,
|
||||
@@ -312,6 +249,7 @@ def container_handler(request, tag=None, package_id=None, branch=None, version_g
|
||||
except ItemNotFoundError:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
component_templates = _get_component_templates(course)
|
||||
ancestor_xblocks = []
|
||||
parent = get_parent_xblock(xblock)
|
||||
while parent and parent.category != 'sequential':
|
||||
@@ -329,11 +267,106 @@ def container_handler(request, tag=None, package_id=None, branch=None, version_g
|
||||
'unit': unit,
|
||||
'unit_publish_state': unit_publish_state,
|
||||
'ancestor_xblocks': ancestor_xblocks,
|
||||
'component_templates': json.dumps(component_templates),
|
||||
})
|
||||
else:
|
||||
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
|
||||
def _get_item_in_course(request, locator):
|
||||
"""
|
||||
|
||||
@@ -8,7 +8,9 @@ from xmodule.modulestore.django import loc_mapper, modulestore
|
||||
__all__ = ['edge', 'event', 'landing']
|
||||
|
||||
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
|
||||
@@ -57,40 +59,54 @@ def get_parent_xblock(xblock):
|
||||
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
|
||||
not have their own page but are instead shown on the page of their parent. There
|
||||
are a few exceptions:
|
||||
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:
|
||||
- subsections (aka sequential blocks)
|
||||
- chapters
|
||||
- sequentials (aka subsections)
|
||||
- chapters (aka sections)
|
||||
"""
|
||||
category = xblock.category
|
||||
if category in ('course', 'vertical'):
|
||||
|
||||
if is_unit(xblock):
|
||||
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'):
|
||||
return False
|
||||
elif xblock.has_children:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
# All other xblocks with children have their own page
|
||||
return xblock.has_children
|
||||
|
||||
|
||||
def xblock_studio_url(xblock, course=None):
|
||||
"""
|
||||
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
|
||||
category = xblock.category
|
||||
parent_xblock = get_parent_xblock(xblock)
|
||||
if parent_xblock:
|
||||
parent_category = parent_xblock.category
|
||||
else:
|
||||
parent_category = None
|
||||
parent_category = parent_xblock.category if parent_xblock else None
|
||||
if category == 'course':
|
||||
prefix = 'course'
|
||||
elif category == 'vertical' and parent_category == 'sequential':
|
||||
|
||||
@@ -33,7 +33,7 @@ from util.string_utils import str_to_bool
|
||||
from ..utils import get_modulestore
|
||||
|
||||
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 xmodule.modulestore.draft import DIRECT_ONLY_CATEGORIES
|
||||
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
|
||||
|
||||
if 'application/json' in accept_header:
|
||||
store = get_modulestore(old_location)
|
||||
component = store.get_item(old_location)
|
||||
is_read_only = _xblock_is_read_only(component)
|
||||
xblock = store.get_item(old_location)
|
||||
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
|
||||
# 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':
|
||||
try:
|
||||
fragment = component.render('studio_view')
|
||||
fragment = xblock.render('studio_view')
|
||||
# catch exceptions indiscriminately, since after this point they escape the
|
||||
# dungeon and surface as uneditable, unsaveable, and undeletable
|
||||
# component-goblins.
|
||||
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)}))
|
||||
|
||||
# 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 = {
|
||||
'runtime_type': 'studio',
|
||||
'container_view': False,
|
||||
'read_only': is_read_only,
|
||||
'root_xblock': component,
|
||||
'root_xblock': xblock,
|
||||
}
|
||||
# For non-leaf xblocks on the unit page, show the special rendering
|
||||
# which links to the new container page.
|
||||
html = render_to_string('container_xblock_component.html', {
|
||||
'xblock_context': context,
|
||||
'xblock': component,
|
||||
'xblock': xblock,
|
||||
'locator': locator,
|
||||
})
|
||||
return JsonResponse({
|
||||
'html': html,
|
||||
'resources': [],
|
||||
})
|
||||
elif view_name in ('student_view', 'container_preview'):
|
||||
is_container_view = (view_name == 'container_preview')
|
||||
elif view_name in (unit_views + container_views):
|
||||
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
|
||||
# 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
|
||||
'runtime_type': 'studio',
|
||||
'container_view': is_container_view,
|
||||
'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
|
||||
# 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.
|
||||
@@ -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', {
|
||||
'xblock_context': context,
|
||||
'preview': fragment.content,
|
||||
'label': component.display_name or component.scope_ids.block_type,
|
||||
'label': xblock.display_name or xblock.scope_ids.block_type,
|
||||
})
|
||||
else:
|
||||
raise Http404
|
||||
@@ -270,7 +281,7 @@ def xblock_view_handler(request, package_id, view_name, tag=None, branch=None, v
|
||||
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.
|
||||
"""
|
||||
@@ -411,7 +422,7 @@ def _create_item(request):
|
||||
metadata = {}
|
||||
data = None
|
||||
template_id = request.json.get('boilerplate')
|
||||
if template_id is not None:
|
||||
if template_id:
|
||||
clz = parent.runtime.load_block_type(category)
|
||||
if clz is not None:
|
||||
template = clz.get_template(template_id)
|
||||
|
||||
@@ -28,7 +28,7 @@ from util.sandboxing import can_execute_unsafe_code
|
||||
|
||||
import static_replace
|
||||
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 contentstore.views.access import get_user_role
|
||||
@@ -166,6 +166,13 @@ def _load_preview_module(request, 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
|
||||
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.
|
||||
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)
|
||||
is_reorderable = _is_xblock_reorderable(xblock, context)
|
||||
template_context = {
|
||||
'xblock_context': context,
|
||||
'xblock': xblock,
|
||||
'locator': locator,
|
||||
'content': frag.content,
|
||||
'is_root': is_root,
|
||||
'is_reorderable': is_reorderable,
|
||||
}
|
||||
if xblock.category == 'vertical':
|
||||
template = 'studio_vertical_wrapper.html'
|
||||
elif xblock.location != context.get('root_xblock').location and xblock.has_children:
|
||||
template = 'container_xblock_component.html'
|
||||
# For child xblocks with their own page, render a link to the page
|
||||
if xblock_has_own_studio_page(xblock) and not is_root:
|
||||
template = 'studio_container_wrapper.html'
|
||||
else:
|
||||
template = 'studio_xblock_wrapper.html'
|
||||
html = render_to_string(template, template_context)
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
"""
|
||||
Unit tests for the container view.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from contentstore.tests.utils import CourseTestCase
|
||||
from contentstore.utils import compute_publish_state, PublishState
|
||||
from contentstore.views.helpers import xblock_studio_url
|
||||
from xmodule.modulestore.django import loc_mapper, modulestore
|
||||
from xmodule.modulestore.tests.factories import ItemFactory
|
||||
|
||||
|
||||
class ContainerViewTestCase(CourseTestCase):
|
||||
"""
|
||||
Unit tests for the container view.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(ContainerViewTestCase, self).setUp()
|
||||
self.chapter = ItemFactory.create(parent_location=self.course.location,
|
||||
category='chapter', display_name="Week 1")
|
||||
self.sequential = ItemFactory.create(parent_location=self.chapter.location,
|
||||
category='sequential', display_name="Lesson 1")
|
||||
self.vertical = ItemFactory.create(parent_location=self.sequential.location,
|
||||
category='vertical', display_name='Unit')
|
||||
self.child_vertical = ItemFactory.create(parent_location=self.vertical.location,
|
||||
category='vertical', display_name='Child Vertical')
|
||||
self.video = ItemFactory.create(parent_location=self.child_vertical.location,
|
||||
category="video", display_name="My Video")
|
||||
|
||||
def test_container_html(self):
|
||||
branch_name = "MITx.999.Robot_Super_Course/branch/draft/block"
|
||||
self._test_html_content(
|
||||
self.child_vertical,
|
||||
branch_name=branch_name,
|
||||
expected_section_tag=(
|
||||
'<section class="wrapper-xblock level-page is-hidden" '
|
||||
'data-locator="{branch_name}/Child_Vertical">'.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="#" class="navigation-link navigation-current">Child Vertical</a>'
|
||||
).format(branch_name=branch_name)
|
||||
)
|
||||
|
||||
def test_container_on_container_html(self):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
published_xblock_with_child = ItemFactory.create(
|
||||
parent_location=self.child_vertical.location,
|
||||
category="wrapper", display_name="Wrapper"
|
||||
)
|
||||
ItemFactory.create(
|
||||
parent_location=published_xblock_with_child.location,
|
||||
category="html", display_name="Child HTML"
|
||||
)
|
||||
branch_name = "MITx.999.Robot_Super_Course/branch/draft/block"
|
||||
self._test_html_content(
|
||||
published_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)
|
||||
)
|
||||
|
||||
# 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.child_vertical.location)
|
||||
draft_xblock_with_child = modulestore('draft').convert_to_draft(published_xblock_with_child.location)
|
||||
self._test_html_content(
|
||||
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):
|
||||
"""
|
||||
Get the HTML for a container page and verify the section tag is correct
|
||||
and the breadcrumbs trail is correct.
|
||||
"""
|
||||
url = xblock_studio_url(xblock, self.course)
|
||||
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)
|
||||
# Verify the navigation link at the top of the page is correct.
|
||||
self.assertRegexpMatches(html, expected_breadcrumbs)
|
||||
# Verify the link that allows users to change publish status.
|
||||
expected_message = None
|
||||
if publish_state == PublishState.public:
|
||||
expected_message = 'you need to edit unit <a href="/unit/{branch_name}/Unit">Unit</a> as a draft.'
|
||||
else:
|
||||
expected_message = 'your changes will be published with unit <a href="/unit/{branch_name}/Unit">Unit</a>.'
|
||||
expected_unit_link = expected_message.format(
|
||||
branch_name=branch_name
|
||||
)
|
||||
self.assertIn(expected_unit_link, html)
|
||||
|
||||
def test_container_preview_html(self):
|
||||
"""
|
||||
Verify that an xblock returns the expected HTML for a container preview
|
||||
"""
|
||||
# First verify that the behavior is correct with a published container
|
||||
self._test_preview_html(self.vertical)
|
||||
self._test_preview_html(self.child_vertical)
|
||||
|
||||
# Now make the unit and its children into a draft and validate the preview again
|
||||
draft_unit = modulestore('draft').convert_to_draft(self.vertical.location)
|
||||
draft_container = modulestore('draft').convert_to_draft(self.child_vertical.location)
|
||||
self._test_preview_html(draft_unit)
|
||||
self._test_preview_html(draft_container)
|
||||
|
||||
def _test_preview_html(self, xblock):
|
||||
"""
|
||||
Verify that the specified xblock has the expected HTML elements for container preview
|
||||
"""
|
||||
locator = loc_mapper().translate_location(self.course.id, xblock.location, published=False)
|
||||
publish_state = compute_publish_state(xblock)
|
||||
preview_url = '/xblock/{locator}/container_preview'.format(locator=locator)
|
||||
|
||||
resp = self.client.get(preview_url, HTTP_ACCEPT='application/json')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
resp_content = json.loads(resp.content)
|
||||
html = resp_content['html']
|
||||
|
||||
# Verify that there are no drag handles for public pages
|
||||
drag_handle_html = '<span data-tooltip="Drag to reorder" class="drag-handle action"></span>'
|
||||
if publish_state == PublishState.public:
|
||||
self.assertNotIn(drag_handle_html, html)
|
||||
else:
|
||||
self.assertIn(drag_handle_html, html)
|
||||
158
cms/djangoapps/contentstore/views/tests/test_container_page.py
Normal file
158
cms/djangoapps/contentstore/views/tests/test_container_page.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Unit tests for the container page.
|
||||
"""
|
||||
|
||||
from contentstore.utils import compute_publish_state, PublishState
|
||||
from contentstore.views.tests.utils import StudioPageTestCase
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.factories import ItemFactory
|
||||
|
||||
|
||||
class ContainerPageTestCase(StudioPageTestCase):
|
||||
"""
|
||||
Unit tests for the container page.
|
||||
"""
|
||||
|
||||
container_view = 'container_preview'
|
||||
reorderable_child_view = 'reorderable_container_child_preview'
|
||||
|
||||
def setUp(self):
|
||||
super(ContainerPageTestCase, self).setUp()
|
||||
self.vertical = ItemFactory.create(parent_location=self.sequential.location,
|
||||
category='vertical', display_name='Unit')
|
||||
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')
|
||||
self.video = ItemFactory.create(parent_location=self.child_vertical.location,
|
||||
category="video", display_name="My Video")
|
||||
|
||||
def test_container_html(self):
|
||||
branch_name = "MITx.999.Robot_Super_Course/branch/draft/block"
|
||||
self._test_html_content(
|
||||
self.child_container,
|
||||
branch_name=branch_name,
|
||||
expected_section_tag=(
|
||||
'<section class="wrapper-xblock level-page is-hidden studio-xblock-wrapper" '
|
||||
'data-locator="{branch_name}/Split_Test">'.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="#" class="navigation-link navigation-current">Split Test</a>'
|
||||
).format(branch_name=branch_name)
|
||||
)
|
||||
|
||||
def test_container_on_container_html(self):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
published_container = ItemFactory.create(
|
||||
parent_location=self.child_container.location,
|
||||
category="wrapper", display_name="Wrapper"
|
||||
)
|
||||
ItemFactory.create(
|
||||
parent_location=published_container.location,
|
||||
category="html", display_name="Child HTML"
|
||||
)
|
||||
|
||||
def test_container_html(xblock):
|
||||
branch_name = "MITx.999.Robot_Super_Course/branch/draft/block"
|
||||
self._test_html_content(
|
||||
xblock,
|
||||
branch_name=branch_name,
|
||||
expected_section_tag=(
|
||||
'<section class="wrapper-xblock level-page is-hidden studio-xblock-wrapper" '
|
||||
'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}/Split_Test"\s*'
|
||||
r'class="navigation-link navigation-parent">Split Test</a>\s*'
|
||||
r'<a href="#" class="navigation-link navigation-current">Wrapper</a>'
|
||||
).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
|
||||
modulestore('draft').convert_to_draft(self.vertical.location)
|
||||
modulestore('draft').convert_to_draft(self.child_vertical.location)
|
||||
draft_container = modulestore('draft').convert_to_draft(published_container.location)
|
||||
test_container_html(draft_container)
|
||||
|
||||
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
|
||||
and the breadcrumbs trail is correct.
|
||||
"""
|
||||
html = self.get_page_html(xblock)
|
||||
publish_state = compute_publish_state(xblock)
|
||||
self.assertIn(expected_section_tag, html)
|
||||
# Verify the navigation link at the top of the page is correct.
|
||||
self.assertRegexpMatches(html, expected_breadcrumbs)
|
||||
|
||||
# Verify the link that allows users to change publish status.
|
||||
expected_message = None
|
||||
if publish_state == PublishState.public:
|
||||
expected_message = 'you need to edit unit <a href="/unit/{branch_name}/Unit">Unit</a> as a draft.'
|
||||
else:
|
||||
expected_message = 'your changes will be published with unit <a href="/unit/{branch_name}/Unit">Unit</a>.'
|
||||
expected_unit_link = expected_message.format(
|
||||
branch_name=branch_name
|
||||
)
|
||||
self.assertIn(expected_unit_link, html)
|
||||
|
||||
def test_public_container_preview_html(self):
|
||||
"""
|
||||
Verify that a public xblock's container preview returns the expected HTML.
|
||||
"""
|
||||
self.validate_preview_html(self.vertical, self.container_view,
|
||||
can_edit=False, can_reorder=False, can_add=False)
|
||||
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)
|
||||
|
||||
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_child_container = modulestore('draft').convert_to_draft(self.child_container.location)
|
||||
draft_child_vertical = modulestore('draft').convert_to_draft(self.child_vertical.location)
|
||||
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_public_child_container_preview_html(self):
|
||||
"""
|
||||
Verify that a public container rendered as a child of the container page returns the expected HTML.
|
||||
"""
|
||||
empty_child_container = ItemFactory.create(parent_location=self.vertical.location,
|
||||
category='split_test', display_name='Split Test')
|
||||
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)
|
||||
|
||||
def test_draft_child_container_preview_html(self):
|
||||
"""
|
||||
Verify that a draft container rendered as a child of the container page returns the expected HTML.
|
||||
"""
|
||||
empty_child_container = ItemFactory.create(parent_location=self.vertical.location,
|
||||
category='split_test', display_name='Split Test')
|
||||
ItemFactory.create(parent_location=empty_child_container.location,
|
||||
category='html', display_name='Split Child')
|
||||
modulestore('draft').convert_to_draft(self.vertical.location)
|
||||
draft_empty_child_container = modulestore('draft').convert_to_draft(empty_child_container.location)
|
||||
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):
|
||||
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">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)
|
||||
|
||||
|
||||
|
||||
|
||||
77
cms/djangoapps/contentstore/views/tests/test_unit_page.py
Normal file
77
cms/djangoapps/contentstore/views/tests/test_unit_page.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
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)
|
||||
81
cms/djangoapps/contentstore/views/tests/utils.py
Normal file
81
cms/djangoapps/contentstore/views/tests/utils.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
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 = {
|
||||
|
||||
# Turn off Advanced Security by default
|
||||
'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
|
||||
|
||||
|
||||
@@ -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="delete-button"><span class="delete-icon white"></span>Delete</a>
|
||||
</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">
|
||||
<div id="stub-module-content"/>
|
||||
</section>
|
||||
|
||||
@@ -1,317 +1,273 @@
|
||||
define ["jquery", "jquery.ui", "gettext", "backbone",
|
||||
"js/views/feedback_notification", "js/views/feedback_prompt",
|
||||
"coffee/src/views/module_edit", "js/models/module_info",
|
||||
"js/views/baseview"],
|
||||
($, ui, gettext, Backbone, NotificationView, PromptView, ModuleEditView, ModuleModel, BaseView) ->
|
||||
class UnitEditView extends BaseView
|
||||
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 .create-draft': 'createDraft'
|
||||
'click .publish-draft': 'publishDraft'
|
||||
'change .visibility-select': 'setVisibility'
|
||||
"click .component-actions .duplicate-button": 'duplicateComponent'
|
||||
"js/views/baseview", "js/views/components/add_xblock"],
|
||||
($, ui, gettext, Backbone, NotificationView, PromptView, ModuleEditView, ModuleModel, BaseView, AddXBlockComponent) ->
|
||||
class UnitEditView extends BaseView
|
||||
events:
|
||||
'click .delete-draft': 'deleteDraft'
|
||||
'click .create-draft': 'createDraft'
|
||||
'click .publish-draft': 'publishDraft'
|
||||
'change .visibility-select': 'setVisibility'
|
||||
"click .component-actions .duplicate-button": 'duplicateComponent'
|
||||
|
||||
initialize: =>
|
||||
@visibilityView = new UnitEditView.Visibility(
|
||||
el: @$('.visibility-select')
|
||||
model: @model
|
||||
)
|
||||
initialize: =>
|
||||
@visibilityView = new UnitEditView.Visibility(
|
||||
el: @$('.visibility-select')
|
||||
model: @model
|
||||
)
|
||||
|
||||
@locationView = new UnitEditView.LocationState(
|
||||
el: @$('.section-item.editing a')
|
||||
model: @model
|
||||
)
|
||||
@locationView = new UnitEditView.LocationState(
|
||||
el: @$('.section-item.editing a')
|
||||
model: @model
|
||||
)
|
||||
|
||||
@nameView = new UnitEditView.NameEdit(
|
||||
el: @$('.unit-name-input')
|
||||
model: @model
|
||||
)
|
||||
@nameView = new UnitEditView.NameEdit(
|
||||
el: @$('.unit-name-input')
|
||||
model: @model
|
||||
)
|
||||
|
||||
@model.on('change:state', @render)
|
||||
@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()
|
||||
|
||||
@$newComponentItem = @$('.new-component-item')
|
||||
@$newComponentTypePicker = @$('.new-component')
|
||||
@$newComponentTemplatePickers = @$('.new-component-templates')
|
||||
@$newComponentButton = @$('.new-component-button')
|
||||
@model.on('change:state', @render)
|
||||
|
||||
@$('.components').sortable(
|
||||
handle: '.drag-handle'
|
||||
update: (event, ui) =>
|
||||
analytics.track "Reordered Components",
|
||||
course: course_location_analytics
|
||||
id: unit_location_analytics
|
||||
@$newComponentItem = @$('.new-component-item')
|
||||
|
||||
payload = children : @components()
|
||||
saving = new NotificationView.Mini
|
||||
title: gettext('Saving…')
|
||||
saving.show()
|
||||
options = success : =>
|
||||
@model.unset('children')
|
||||
saving.hide()
|
||||
@model.save(payload, options)
|
||||
helper: 'clone'
|
||||
opacity: '0.5'
|
||||
placeholder: 'component-placeholder'
|
||||
forcePlaceholderSize: true
|
||||
axis: 'y'
|
||||
items: '> .component'
|
||||
)
|
||||
@$('.components').sortable(
|
||||
handle: '.drag-handle'
|
||||
update: (event, ui) =>
|
||||
analytics.track "Reordered Components",
|
||||
course: course_location_analytics
|
||||
id: unit_location_analytics
|
||||
|
||||
@$('.component').each (idx, element) =>
|
||||
model = new ModuleModel
|
||||
id: $(element).data('locator')
|
||||
new ModuleEditView
|
||||
el: element,
|
||||
onDelete: @deleteComponent,
|
||||
model: model
|
||||
payload = children : @components()
|
||||
saving = new NotificationView.Mini
|
||||
title: gettext('Saving…')
|
||||
saving.show()
|
||||
options = success : =>
|
||||
@model.unset('children')
|
||||
saving.hide()
|
||||
@model.save(payload, options)
|
||||
helper: 'clone'
|
||||
opacity: '0.5'
|
||||
placeholder: 'component-placeholder'
|
||||
forcePlaceholderSize: true
|
||||
axis: 'y'
|
||||
items: '> .component'
|
||||
)
|
||||
|
||||
showComponentTemplates: (event) =>
|
||||
event.preventDefault()
|
||||
@$('.component').each (idx, element) =>
|
||||
model = new ModuleModel
|
||||
id: $(element).data('locator')
|
||||
new ModuleEditView
|
||||
el: element,
|
||||
onDelete: @deleteComponent,
|
||||
model: model
|
||||
|
||||
type = $(event.currentTarget).data('type')
|
||||
@$newComponentTypePicker.slideUp(250)
|
||||
@$(".new-component-#{type}").slideDown(250)
|
||||
$('html, body').animate({
|
||||
scrollTop: @$(".new-component-#{type}").offset().top
|
||||
}, 500)
|
||||
createComponent: (data, analytics_message) =>
|
||||
self = this
|
||||
operation = $.Deferred()
|
||||
editor = new ModuleEditView(
|
||||
onDelete: @deleteComponent
|
||||
model: new ModuleModel()
|
||||
)
|
||||
|
||||
closeNewComponent: (event) =>
|
||||
event.preventDefault()
|
||||
callback = ->
|
||||
operation.resolveWith(self, [editor])
|
||||
analytics.track analytics_message,
|
||||
course: course_location_analytics
|
||||
unit_id: unit_location_analytics
|
||||
type: editor.$el.data('locator')
|
||||
|
||||
@$newComponentTypePicker.slideDown(250)
|
||||
@$newComponentTemplatePickers.slideUp(250)
|
||||
@$newComponentItem.removeClass('adding')
|
||||
@$newComponentItem.find('.rendered-component').remove()
|
||||
editor.createItem(
|
||||
@$el.data('locator'),
|
||||
data,
|
||||
callback
|
||||
)
|
||||
|
||||
createComponent: (event, data, notification_message, analytics_message, success_callback) =>
|
||||
event.preventDefault()
|
||||
return operation.promise()
|
||||
|
||||
editor = new ModuleEditView(
|
||||
onDelete: @deleteComponent
|
||||
model: new ModuleModel()
|
||||
)
|
||||
duplicateComponent: (event) =>
|
||||
self = this
|
||||
event.preventDefault()
|
||||
$component = $(event.currentTarget).parents('.component')
|
||||
source_locator = $component.data('locator')
|
||||
@runOperationShowingMessage(gettext('Duplicating…'), ->
|
||||
operation = self.createComponent(
|
||||
{duplicate_source_locator: source_locator},
|
||||
"Duplicating " + source_locator);
|
||||
operation.done(
|
||||
(editor) ->
|
||||
originalOffset = @getScrollOffset($component)
|
||||
$component.after(editor.$el)
|
||||
# Scroll the window so that the new component replaces the old one
|
||||
@setScrollOffset(editor.$el, originalOffset)
|
||||
))
|
||||
|
||||
notification = new NotificationView.Mini
|
||||
title: notification_message
|
||||
components: => @$('.component').map((idx, el) -> $(el).data('locator')).get()
|
||||
|
||||
notification.show()
|
||||
wait: (value) =>
|
||||
@$('.unit-body').toggleClass("waiting", value)
|
||||
|
||||
callback = ->
|
||||
notification.hide()
|
||||
success_callback()
|
||||
analytics.track analytics_message,
|
||||
course: course_location_analytics
|
||||
unit_id: unit_location_analytics
|
||||
type: editor.$el.data('locator')
|
||||
render: =>
|
||||
if @model.hasChanged('state')
|
||||
@$el.toggleClass("edit-state-#{@model.previous('state')} edit-state-#{@model.get('state')}")
|
||||
@wait(false)
|
||||
|
||||
editor.createItem(
|
||||
@$el.data('locator'),
|
||||
data,
|
||||
callback
|
||||
)
|
||||
saveDraft: =>
|
||||
@model.save()
|
||||
|
||||
return editor
|
||||
deleteComponent: (event) =>
|
||||
self = this
|
||||
event.preventDefault()
|
||||
@confirmThenRunOperation(gettext('Delete this component?'),
|
||||
gettext('Deleting this component is permanent and cannot be undone.'),
|
||||
gettext('Yes, delete this component'),
|
||||
->
|
||||
self.runOperationShowingMessage(gettext('Deleting…'),
|
||||
->
|
||||
$component = $(event.currentTarget).parents('.component')
|
||||
return $.ajax({
|
||||
type: 'DELETE',
|
||||
url: self.model.urlRoot + "/" + $component.data('locator')
|
||||
}).success(=>
|
||||
analytics.track "Deleted a Component",
|
||||
course: course_location_analytics
|
||||
unit_id: unit_location_analytics
|
||||
id: $component.data('locator')
|
||||
|
||||
saveNewComponent: (event) =>
|
||||
success_callback = =>
|
||||
@$newComponentItem.before(editor.$el)
|
||||
editor = @createComponent(
|
||||
event, $(event.currentTarget).data(),
|
||||
gettext('Adding…'),
|
||||
"Creating new component",
|
||||
success_callback
|
||||
)
|
||||
@closeNewComponent(event)
|
||||
$component.remove()
|
||||
# b/c we don't vigilantly keep children up to date
|
||||
# get rid of it before it hurts someone
|
||||
self.model.save({children: self.components()},
|
||||
{
|
||||
success: (model) ->
|
||||
model.unset('children')
|
||||
})
|
||||
)))
|
||||
|
||||
duplicateComponent: (event) =>
|
||||
$component = $(event.currentTarget).parents('.component')
|
||||
source_locator = $component.data('locator')
|
||||
success_callback = ->
|
||||
$component.after(editor.$el)
|
||||
$('html, body').animate({
|
||||
scrollTop: editor.$el.offset().top
|
||||
}, 500)
|
||||
editor = @createComponent(
|
||||
event,
|
||||
{duplicate_source_locator: source_locator},
|
||||
gettext('Duplicating…')
|
||||
"Duplicating " + source_locator,
|
||||
success_callback
|
||||
)
|
||||
|
||||
components: => @$('.component').map((idx, el) -> $(el).data('locator')).get()
|
||||
|
||||
wait: (value) =>
|
||||
@$('.unit-body').toggleClass("waiting", value)
|
||||
|
||||
render: =>
|
||||
if @model.hasChanged('state')
|
||||
@$el.toggleClass("edit-state-#{@model.previous('state')} edit-state-#{@model.get('state')}")
|
||||
@wait(false)
|
||||
|
||||
saveDraft: =>
|
||||
@model.save()
|
||||
|
||||
deleteComponent: (event) =>
|
||||
event.preventDefault()
|
||||
msg = new PromptView.Warning(
|
||||
title: gettext('Delete this component?'),
|
||||
message: gettext('Deleting this component is permanent and cannot be undone.'),
|
||||
actions:
|
||||
primary:
|
||||
text: gettext('Yes, delete this component'),
|
||||
click: (view) =>
|
||||
view.hide()
|
||||
deleting = new NotificationView.Mini
|
||||
title: gettext('Deleting…'),
|
||||
deleting.show()
|
||||
$component = $(event.currentTarget).parents('.component')
|
||||
$.ajax({
|
||||
deleteDraft: (event) ->
|
||||
@wait(true)
|
||||
$.ajax({
|
||||
type: 'DELETE',
|
||||
url: @model.urlRoot + "/" + $component.data('locator')
|
||||
}).success(=>
|
||||
deleting.hide()
|
||||
analytics.track "Deleted a Component",
|
||||
course: course_location_analytics
|
||||
unit_id: unit_location_analytics
|
||||
id: $component.data('locator')
|
||||
url: @model.url() + "?" + $.param({recurse: true})
|
||||
}).success(=>
|
||||
|
||||
$component.remove()
|
||||
# b/c we don't vigilantly keep children up to date
|
||||
# get rid of it before it hurts someone
|
||||
# sorry for the js, i couldn't figure out the coffee equivalent
|
||||
`_this.model.save({children: _this.components()},
|
||||
{success: function(model) {
|
||||
model.unset('children');
|
||||
}}
|
||||
);`
|
||||
)
|
||||
secondary:
|
||||
text: gettext('Cancel'),
|
||||
click: (view) ->
|
||||
view.hide()
|
||||
)
|
||||
msg.show()
|
||||
analytics.track "Deleted Draft",
|
||||
course: course_location_analytics
|
||||
unit_id: unit_location_analytics
|
||||
|
||||
deleteDraft: (event) ->
|
||||
@wait(true)
|
||||
$.ajax({
|
||||
type: 'DELETE',
|
||||
url: @model.url() + "?" + $.param({recurse: true})
|
||||
}).success(=>
|
||||
window.location.reload()
|
||||
)
|
||||
|
||||
analytics.track "Deleted Draft",
|
||||
course: course_location_analytics
|
||||
unit_id: unit_location_analytics
|
||||
createDraft: (event) ->
|
||||
self = this
|
||||
@disableElementWhileRunning($(event.target), ->
|
||||
self.wait(true)
|
||||
$.postJSON(self.model.url(), {
|
||||
publish: 'create_draft'
|
||||
}, =>
|
||||
analytics.track "Created Draft",
|
||||
course: course_location_analytics
|
||||
unit_id: unit_location_analytics
|
||||
|
||||
window.location.reload()
|
||||
)
|
||||
self.model.set('state', 'draft')
|
||||
)
|
||||
)
|
||||
|
||||
createDraft: (event) ->
|
||||
self = this
|
||||
@disableElementWhileRunning($(event.target), ->
|
||||
self.wait(true)
|
||||
$.postJSON(self.model.url(), {
|
||||
publish: 'create_draft'
|
||||
}, =>
|
||||
analytics.track "Created Draft",
|
||||
course: course_location_analytics
|
||||
unit_id: unit_location_analytics
|
||||
publishDraft: (event) ->
|
||||
self = this
|
||||
@disableElementWhileRunning($(event.target), ->
|
||||
self.wait(true)
|
||||
self.saveDraft()
|
||||
|
||||
self.model.set('state', 'draft')
|
||||
)
|
||||
)
|
||||
$.postJSON(self.model.url(), {
|
||||
publish: 'make_public'
|
||||
}, =>
|
||||
analytics.track "Published Draft",
|
||||
course: course_location_analytics
|
||||
unit_id: unit_location_analytics
|
||||
|
||||
publishDraft: (event) ->
|
||||
self = this
|
||||
@disableElementWhileRunning($(event.target), ->
|
||||
self.wait(true)
|
||||
self.saveDraft()
|
||||
self.model.set('state', 'public')
|
||||
)
|
||||
)
|
||||
|
||||
$.postJSON(self.model.url(), {
|
||||
publish: 'make_public'
|
||||
}, =>
|
||||
analytics.track "Published Draft",
|
||||
course: course_location_analytics
|
||||
unit_id: unit_location_analytics
|
||||
setVisibility: (event) ->
|
||||
if @$('.visibility-select').val() == 'private'
|
||||
action = 'make_private'
|
||||
visibility = "private"
|
||||
else
|
||||
action = 'make_public'
|
||||
visibility = "public"
|
||||
|
||||
self.model.set('state', 'public')
|
||||
)
|
||||
)
|
||||
@wait(true)
|
||||
|
||||
setVisibility: (event) ->
|
||||
if @$('.visibility-select').val() == 'private'
|
||||
action = 'make_private'
|
||||
visibility = "private"
|
||||
else
|
||||
action = 'make_public'
|
||||
visibility = "public"
|
||||
$.postJSON(@model.url(), {
|
||||
publish: action
|
||||
}, =>
|
||||
analytics.track "Set Unit Visibility",
|
||||
course: course_location_analytics
|
||||
unit_id: unit_location_analytics
|
||||
visibility: visibility
|
||||
|
||||
@wait(true)
|
||||
@model.set('state', @$('.visibility-select').val()))
|
||||
|
||||
$.postJSON(@model.url(), {
|
||||
publish: action
|
||||
}, =>
|
||||
analytics.track "Set Unit Visibility",
|
||||
course: course_location_analytics
|
||||
unit_id: unit_location_analytics
|
||||
visibility: visibility
|
||||
class UnitEditView.NameEdit extends BaseView
|
||||
events:
|
||||
'change .unit-display-name-input': 'saveName'
|
||||
|
||||
@model.set('state', @$('.visibility-select').val())
|
||||
)
|
||||
initialize: =>
|
||||
@model.on('change:metadata', @render)
|
||||
@model.on('change:state', @setEnabled)
|
||||
@setEnabled()
|
||||
@saveName
|
||||
@$spinner = $('<span class="spinner-in-field-icon"></span>');
|
||||
|
||||
class UnitEditView.NameEdit extends BaseView
|
||||
events:
|
||||
'change .unit-display-name-input': 'saveName'
|
||||
render: =>
|
||||
@$('.unit-display-name-input').val(@model.get('metadata').display_name)
|
||||
|
||||
initialize: =>
|
||||
@model.on('change:metadata', @render)
|
||||
@model.on('change:state', @setEnabled)
|
||||
@setEnabled()
|
||||
@saveName
|
||||
@$spinner = $('<span class="spinner-in-field-icon"></span>');
|
||||
setEnabled: =>
|
||||
disabled = @model.get('state') == 'public'
|
||||
if disabled
|
||||
@$('.unit-display-name-input').attr('disabled', true)
|
||||
else
|
||||
@$('.unit-display-name-input').removeAttr('disabled')
|
||||
|
||||
render: =>
|
||||
@$('.unit-display-name-input').val(@model.get('metadata').display_name)
|
||||
|
||||
setEnabled: =>
|
||||
disabled = @model.get('state') == 'public'
|
||||
if disabled
|
||||
@$('.unit-display-name-input').attr('disabled', true)
|
||||
else
|
||||
@$('.unit-display-name-input').removeAttr('disabled')
|
||||
|
||||
saveName: =>
|
||||
# Treat the metadata dictionary as immutable
|
||||
metadata = $.extend({}, @model.get('metadata'))
|
||||
metadata.display_name = @$('.unit-display-name-input').val()
|
||||
@model.save(metadata: metadata)
|
||||
# Update name shown in the right-hand side location summary.
|
||||
$('.unit-location .editing .unit-name').html(metadata.display_name)
|
||||
analytics.track "Edited Unit Name",
|
||||
course: course_location_analytics
|
||||
unit_id: unit_location_analytics
|
||||
display_name: metadata.display_name
|
||||
saveName: =>
|
||||
# Treat the metadata dictionary as immutable
|
||||
metadata = $.extend({}, @model.get('metadata'))
|
||||
metadata.display_name = @$('.unit-display-name-input').val()
|
||||
@model.save(metadata: metadata)
|
||||
# Update name shown in the right-hand side location summary.
|
||||
$('.unit-location .editing .unit-name').html(metadata.display_name)
|
||||
analytics.track "Edited Unit Name",
|
||||
course: course_location_analytics
|
||||
unit_id: unit_location_analytics
|
||||
display_name: metadata.display_name
|
||||
|
||||
|
||||
class UnitEditView.LocationState extends BaseView
|
||||
initialize: =>
|
||||
@model.on('change:state', @render)
|
||||
class UnitEditView.LocationState extends BaseView
|
||||
initialize: =>
|
||||
@model.on('change:state', @render)
|
||||
|
||||
render: =>
|
||||
@$el.toggleClass("#{@model.previous('state')}-item #{@model.get('state')}-item")
|
||||
render: =>
|
||||
@$el.toggleClass("#{@model.previous('state')}-item #{@model.get('state')}-item")
|
||||
|
||||
class UnitEditView.Visibility extends BaseView
|
||||
initialize: =>
|
||||
@model.on('change:state', @render)
|
||||
@render()
|
||||
class UnitEditView.Visibility extends BaseView
|
||||
initialize: =>
|
||||
@model.on('change:state', @render)
|
||||
@render()
|
||||
|
||||
render: =>
|
||||
@$el.val(@model.get('state'))
|
||||
render: =>
|
||||
@$el.val(@model.get('state'))
|
||||
|
||||
return UnitEditView
|
||||
return UnitEditView
|
||||
|
||||
5
cms/static/js/collections/component_template.js
Normal file
5
cms/static/js/collections/component_template.js
Normal file
@@ -0,0 +1,5 @@
|
||||
define(["backbone", "js/models/component_template"], function(Backbone, ComponentTemplate) {
|
||||
return Backbone.Collection.extend({
|
||||
model : ComponentTemplate
|
||||
});
|
||||
});
|
||||
31
cms/static/js/models/component_template.js
Normal file
31
cms/static/js/models/component_template.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
define(["jquery", "underscore", "js/views/baseview", "js/utils/handle_iframe_binding", "sinon"],
|
||||
function ($, _, BaseView, IframeBinding, sinon) {
|
||||
define(["jquery", "underscore", "js/views/baseview", "js/utils/handle_iframe_binding", "sinon",
|
||||
"js/spec_helpers/edit_helpers"],
|
||||
function ($, _, BaseView, IframeBinding, sinon, view_helpers) {
|
||||
|
||||
describe("BaseView", function() {
|
||||
var baseViewPrototype;
|
||||
@@ -79,8 +80,7 @@ define(["jquery", "underscore", "js/views/baseview", "js/utils/handle_iframe_bin
|
||||
|
||||
describe("disabled element while running", function() {
|
||||
it("adds 'is-disabled' class to element while action is running and removes it after", function() {
|
||||
var viewWithLink,
|
||||
link,
|
||||
var link,
|
||||
deferred = new $.Deferred(),
|
||||
promise = deferred.promise(),
|
||||
view = new BaseView();
|
||||
@@ -89,11 +89,37 @@ define(["jquery", "underscore", "js/views/baseview", "js/utils/handle_iframe_bin
|
||||
|
||||
link = $("#link");
|
||||
expect(link).not.toHaveClass("is-disabled");
|
||||
view.disableElementWhileRunning(link, function(){return promise});
|
||||
view.disableElementWhileRunning(link, function() { return promise; });
|
||||
expect(link).toHaveClass("is-disabled");
|
||||
deferred.resolve();
|
||||
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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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"],
|
||||
function ($, create_sinon, view_helpers, ContainerView, XBlockInfo, Notification) {
|
||||
function ($, create_sinon, view_helpers, ContainerView, XBlockInfo) {
|
||||
|
||||
describe("Container View", function () {
|
||||
|
||||
@@ -9,7 +9,7 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers
|
||||
|
||||
var model, containerView, mockContainerHTML, respondWithMockXBlockFragment, init, getComponent,
|
||||
getDragHandle, dragComponentVertically, dragComponentAbove,
|
||||
verifyRequest, verifyNumReorderCalls, respondToRequest,
|
||||
verifyRequest, verifyNumReorderCalls, respondToRequest, notificationSpy,
|
||||
|
||||
rootLocator = 'testCourse/branch/draft/split_test/splitFFF',
|
||||
containerTestUrl = '/xblock/' + rootLocator,
|
||||
@@ -35,7 +35,8 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers
|
||||
|
||||
beforeEach(function () {
|
||||
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({
|
||||
id: rootLocator,
|
||||
display_name: 'Test AB Test',
|
||||
@@ -63,16 +64,29 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers
|
||||
});
|
||||
|
||||
$('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;
|
||||
};
|
||||
|
||||
getComponent = function(locator) {
|
||||
return containerView.$('[data-locator="' + locator + '"]');
|
||||
return containerView.$('.studio-xblock-wrapper[data-locator="' + locator + '"]');
|
||||
};
|
||||
|
||||
getDragHandle = function(locator) {
|
||||
var component = getComponent(locator);
|
||||
return component.prev();
|
||||
return $(component.find('.drag-handle')[0]);
|
||||
};
|
||||
|
||||
dragComponentVertically = function (locator, dy) {
|
||||
@@ -166,31 +180,17 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers
|
||||
});
|
||||
|
||||
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 () {
|
||||
var requests, savingOptions;
|
||||
requests = init(this);
|
||||
|
||||
// Drag the first component in Group B to the first group.
|
||||
dragComponentAbove(groupBComponent1, groupAComponent1);
|
||||
|
||||
expect(savingSpies.constructor).toHaveBeenCalled();
|
||||
expect(savingSpies.show).toHaveBeenCalled();
|
||||
expect(savingSpies.hide).not.toHaveBeenCalled();
|
||||
savingOptions = savingSpies.constructor.mostRecentCall.args[0];
|
||||
expect(savingOptions.title).toMatch(/Saving/);
|
||||
|
||||
view_helpers.verifyNotificationShowing(notificationSpy, 'Saving');
|
||||
respondToRequest(requests, 0, 200);
|
||||
expect(savingSpies.hide).not.toHaveBeenCalled();
|
||||
view_helpers.verifyNotificationShowing(notificationSpy, 'Saving');
|
||||
respondToRequest(requests, 1, 200);
|
||||
expect(savingSpies.hide).toHaveBeenCalled();
|
||||
view_helpers.verifyNotificationHidden(notificationSpy);
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
// Drag the first component in Group B to the first group.
|
||||
dragComponentAbove(groupBComponent1, groupAComponent1);
|
||||
|
||||
expect(savingSpies.constructor).toHaveBeenCalled();
|
||||
expect(savingSpies.show).toHaveBeenCalled();
|
||||
expect(savingSpies.hide).not.toHaveBeenCalled();
|
||||
|
||||
view_helpers.verifyNotificationShowing(notificationSpy, 'Saving');
|
||||
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.
|
||||
verifyNumReorderCalls(requests, 1);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers",
|
||||
"js/views/feedback_notification", "js/views/feedback_prompt",
|
||||
"js/views/pages/container", "js/models/xblock_info"],
|
||||
function ($, create_sinon, edit_helpers, Notification, Prompt, ContainerPage, XBlockInfo) {
|
||||
define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers",
|
||||
"js/views/feedback_prompt", "js/views/pages/container", "js/models/xblock_info"],
|
||||
function ($, _, create_sinon, edit_helpers, Prompt, ContainerPage, XBlockInfo) {
|
||||
|
||||
describe("ContainerPage", function() {
|
||||
var lastRequest, renderContainerPage, expectComponents, respondWithHtml,
|
||||
model, containerPage, requests,
|
||||
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 () {
|
||||
edit_helpers.installEditTemplates();
|
||||
@@ -20,6 +20,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
|
||||
});
|
||||
containerPage = new ContainerPage({
|
||||
model: model,
|
||||
templates: edit_helpers.mockComponentTemplates,
|
||||
el: $('#content')
|
||||
});
|
||||
});
|
||||
@@ -43,7 +44,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
|
||||
|
||||
expectComponents = function (container, 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);
|
||||
_.each(locators, function(locator, locator_index) {
|
||||
expect($(components[locator_index]).data('locator')).toBe(locator);
|
||||
@@ -51,8 +52,6 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
|
||||
};
|
||||
|
||||
describe("Basic display", function() {
|
||||
var mockContainerXBlockHtml = readFixtures('mock/mock-container-xblock.underscore');
|
||||
|
||||
it('can render itself', function() {
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
expect(containerPage.$el.select('.xblock-header')).toBeTruthy();
|
||||
@@ -69,9 +68,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
|
||||
});
|
||||
|
||||
describe("Editing an xblock", function() {
|
||||
var mockContainerXBlockHtml,
|
||||
mockXBlockEditorHtml,
|
||||
newDisplayName = 'New Display Name';
|
||||
var newDisplayName = 'New Display Name';
|
||||
|
||||
beforeEach(function () {
|
||||
edit_helpers.installMockXBlock({
|
||||
@@ -87,9 +84,6 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
|
||||
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() {
|
||||
var editButtons;
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
@@ -110,8 +104,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
|
||||
});
|
||||
|
||||
describe("Editing an xmodule", function() {
|
||||
var mockContainerXBlockHtml,
|
||||
mockXModuleEditor,
|
||||
var mockXModuleEditor = readFixtures('mock/mock-xmodule-editor.underscore'),
|
||||
newDisplayName = 'New Display Name';
|
||||
|
||||
beforeEach(function () {
|
||||
@@ -128,9 +121,6 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
|
||||
edit_helpers.cancelModalIfShowing();
|
||||
});
|
||||
|
||||
mockContainerXBlockHtml = readFixtures('mock/mock-container-xblock.underscore');
|
||||
mockXModuleEditor = readFixtures('mock/mock-xmodule-editor.underscore');
|
||||
|
||||
it('can save changes to settings', function() {
|
||||
var editButtons, modal, mockUpdatedXBlockHtml;
|
||||
mockUpdatedXBlockHtml = readFixtures('mock/mock-updated-xblock.underscore');
|
||||
@@ -165,43 +155,32 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
|
||||
});
|
||||
|
||||
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() {
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
renderContainerPage(mockEmptyContainerXBlockHtml, this);
|
||||
expect(containerPage.$('.no-container-content')).not.toHaveClass('is-hidden');
|
||||
expect(containerPage.$('.wrapper-xblock')).toHaveClass('is-hidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe("xblock operations", function() {
|
||||
var getGroupElement, expectNumComponents, expectNotificationToBeShown,
|
||||
var getGroupElement, expectNumComponents,
|
||||
NUM_GROUPS = 2, NUM_COMPONENTS_PER_GROUP = 3, GROUP_TO_TEST = "A",
|
||||
notificationSpies,
|
||||
allComponentsInGroup = _.map(
|
||||
_.range(NUM_COMPONENTS_PER_GROUP),
|
||||
function(index) { return 'locator-component-' + GROUP_TO_TEST + (index + 1); }
|
||||
);
|
||||
|
||||
beforeEach(function () {
|
||||
notificationSpies = spyOnConstructor(Notification, "Mini", ["show", "hide"]);
|
||||
notificationSpies.show.andReturn(notificationSpies);
|
||||
});
|
||||
|
||||
getGroupElement = function() {
|
||||
return containerPage.$("[data-locator='locator-group-" + GROUP_TO_TEST + "']");
|
||||
};
|
||||
|
||||
expectNumComponents = function(numComponents) {
|
||||
expect(containerPage.$('.wrapper-xblock.level-element').length).toBe(
|
||||
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() {
|
||||
var clickDelete, deleteComponent, deleteComponentWithSuccess,
|
||||
@@ -212,7 +191,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
|
||||
promptSpies.show.andReturn(this.promptSpies);
|
||||
});
|
||||
|
||||
clickDelete = function(componentIndex) {
|
||||
clickDelete = function(componentIndex, clickNo) {
|
||||
|
||||
// find all delete buttons for the given group
|
||||
var deleteButtons = getGroupElement().find(".delete-button");
|
||||
@@ -226,21 +205,18 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
|
||||
|
||||
// no components should be deleted yet
|
||||
expectNumComponents(NUM_COMPONENTS_PER_GROUP);
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
deleteComponent = function(componentIndex, responseCode) {
|
||||
|
||||
// click delete button for given component
|
||||
deleteComponent = function(componentIndex) {
|
||||
clickDelete(componentIndex);
|
||||
|
||||
// click 'Yes' on delete confirmation
|
||||
promptSpies.constructor.mostRecentCall.args[0].actions.primary.click(promptSpies);
|
||||
|
||||
// expect 'deleting' notification to be shown
|
||||
expectNotificationToBeShown(/Deleting/);
|
||||
|
||||
// respond to request with given response code
|
||||
lastRequest().respond(responseCode, {}, "");
|
||||
create_sinon.respondWithJson(requests, {});
|
||||
|
||||
// expect request URL to contain given component's id
|
||||
expect(lastRequest().url).toMatch(
|
||||
@@ -249,12 +225,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
|
||||
};
|
||||
|
||||
deleteComponentWithSuccess = function(componentIndex) {
|
||||
|
||||
// delete component with an 'OK' response code
|
||||
deleteComponent(componentIndex, 200);
|
||||
|
||||
// expect 'deleting' notification to be hidden
|
||||
expect(notificationSpies.hide).toHaveBeenCalled();
|
||||
deleteComponent(componentIndex);
|
||||
|
||||
// verify the new list of components within the group
|
||||
expectComponents(
|
||||
@@ -263,32 +234,29 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
|
||||
);
|
||||
};
|
||||
|
||||
it("deletes first xblock", function() {
|
||||
renderContainerPage(ABTestFixture, this);
|
||||
it("can delete the first xblock", function() {
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
deleteComponentWithSuccess(0);
|
||||
});
|
||||
|
||||
it("deletes middle xblock", function() {
|
||||
renderContainerPage(ABTestFixture, this);
|
||||
it("can delete a middle xblock", function() {
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
deleteComponentWithSuccess(1);
|
||||
});
|
||||
|
||||
it("deletes last xblock", function() {
|
||||
renderContainerPage(ABTestFixture, this);
|
||||
it("can delete the last xblock", function() {
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
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;
|
||||
|
||||
renderContainerPage(ABTestFixture, this);
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
numRequests = requests.length;
|
||||
|
||||
// click delete on the first component
|
||||
clickDelete(0);
|
||||
|
||||
// click 'No' on delete confirmation
|
||||
promptSpies.constructor.mostRecentCall.args[0].actions.secondary.click(promptSpies);
|
||||
// click delete on the first component but press no
|
||||
clickDelete(0, true);
|
||||
|
||||
// all components should still exist
|
||||
expectComponents(getGroupElement(), allComponentsInGroup);
|
||||
@@ -297,11 +265,23 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
|
||||
expect(requests.length).toBe(numRequests);
|
||||
});
|
||||
|
||||
it('does not delete xblock upon failure', function () {
|
||||
renderContainerPage(ABTestFixture, this);
|
||||
deleteComponent(0, 500);
|
||||
it('shows a notification during the delete operation', function() {
|
||||
var notificationSpy = edit_helpers.createNotificationSpy();
|
||||
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);
|
||||
expect(notificationSpies.hide).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -329,16 +309,8 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
|
||||
// click duplicate button for given component
|
||||
clickDuplicate(componentIndex);
|
||||
|
||||
// expect 'duplicating' notification to be shown
|
||||
expectNotificationToBeShown(/Duplicating/);
|
||||
|
||||
// verify content of request
|
||||
request = lastRequest();
|
||||
request.respond(
|
||||
responseCode,
|
||||
{ "Content-Type": "application/json" },
|
||||
JSON.stringify({'locator': 'locator-duplicated-component'})
|
||||
);
|
||||
expect(request.url).toEqual("/xblock");
|
||||
expect(request.method).toEqual("POST");
|
||||
expect(JSON.parse(request.requestBody)).toEqual(
|
||||
@@ -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) {
|
||||
@@ -356,34 +335,117 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
|
||||
// duplicate component with an 'OK' response code
|
||||
duplicateComponentWithResponse(componentIndex, 200);
|
||||
|
||||
// expect 'duplicating' notification to be hidden
|
||||
expect(notificationSpies.hide).toHaveBeenCalled();
|
||||
|
||||
// expect parent container to be refreshed
|
||||
expect(refreshXBlockSpies).toHaveBeenCalled();
|
||||
};
|
||||
|
||||
it("duplicates first xblock", function() {
|
||||
renderContainerPage(ABTestFixture, this);
|
||||
it("can duplicate the first xblock", function() {
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
duplicateComponentWithSuccess(0);
|
||||
});
|
||||
|
||||
it("duplicates middle xblock", function() {
|
||||
renderContainerPage(ABTestFixture, this);
|
||||
it("can duplicate a middle xblock", function() {
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
duplicateComponentWithSuccess(1);
|
||||
});
|
||||
|
||||
it("duplicates last xblock", function() {
|
||||
renderContainerPage(ABTestFixture, this);
|
||||
it("can duplicate the last xblock", function() {
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
duplicateComponentWithSuccess(NUM_COMPONENTS_PER_GROUP - 1);
|
||||
});
|
||||
|
||||
it('does not duplicate xblock upon failure', function () {
|
||||
renderContainerPage(ABTestFixture, this);
|
||||
duplicateComponentWithResponse(0, 500);
|
||||
it('shows a notification when duplicating', function () {
|
||||
var notificationSpy = edit_helpers.createNotificationSpy();
|
||||
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);
|
||||
expect(notificationSpies.hide).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"
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,240 +1,178 @@
|
||||
define(["coffee/src/views/unit", "js/models/module_info", "js/spec_helpers/create_sinon", "js/views/feedback_notification",
|
||||
"jasmine-stealth"],
|
||||
function (UnitEditView, ModuleModel, create_sinon, NotificationView) {
|
||||
var verifyJSON = function (requests, json) {
|
||||
var request = requests[requests.length - 1];
|
||||
expect(request.url).toEqual("/xblock");
|
||||
expect(request.method).toEqual("POST");
|
||||
// There was a problem with order of returned parameters in strings.
|
||||
// Changed to compare objects instead strings.
|
||||
expect(JSON.parse(request.requestBody)).toEqual(JSON.parse(json));
|
||||
define(["jquery", "underscore", "jasmine", "coffee/src/views/unit", "js/models/module_info",
|
||||
"js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers", "jasmine-stealth"],
|
||||
function ($, _, jasmine, UnitEditView, ModuleModel, create_sinon, edit_helpers) {
|
||||
var requests, unitView, initialize, respondWithHtml, verifyComponents, i;
|
||||
|
||||
respondWithHtml = function(html, requestIndex) {
|
||||
create_sinon.respondWithJson(
|
||||
requests,
|
||||
{ html: html, "resources": [] },
|
||||
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");
|
||||
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]);
|
||||
}
|
||||
};
|
||||
|
||||
var verifyNotification = function (notificationSpy, text, requests) {
|
||||
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);
|
||||
create_sinon.respondWithJson(requests, {"locator": "new_item"});
|
||||
expect(notificationSpy.hide).toHaveBeenCalled();
|
||||
};
|
||||
beforeEach(function() {
|
||||
edit_helpers.installMockXBlock();
|
||||
|
||||
describe('duplicateComponent ', function () {
|
||||
var duplicateFixture =
|
||||
'<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"/> \
|
||||
<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'
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
it('sends the correct JSON to the server', function () {
|
||||
var requests = create_sinon.requests(this);
|
||||
clickDuplicate(0);
|
||||
verifyJSON(requests, '{"duplicate_source_locator":"loc_1","parent_locator":"unit_locator"}');
|
||||
});
|
||||
|
||||
it('inserts duplicated component immediately after source upon success', function () {
|
||||
var requests = create_sinon.requests(this);
|
||||
clickDuplicate(0);
|
||||
create_sinon.respondWithJson(requests, {"locator": "duplicated_item"});
|
||||
verifyComponents(unit, ['loc_1', 'duplicated_item', 'loc_2']);
|
||||
});
|
||||
|
||||
it('inserts duplicated component at end if source at end', function () {
|
||||
var requests = create_sinon.requests(this);
|
||||
clickDuplicate(1);
|
||||
create_sinon.respondWithJson(requests, {"locator": "duplicated_item"});
|
||||
verifyComponents(unit, ['loc_1', 'loc_2', 'duplicated_item']);
|
||||
});
|
||||
|
||||
it('shows a notification while duplicating', function () {
|
||||
var notificationSpy = spyOnConstructor(NotificationView, "Mini", ["show", "hide"]);
|
||||
notificationSpy.show.andReturn(notificationSpy);
|
||||
|
||||
var requests = create_sinon.requests(this);
|
||||
clickDuplicate(0);
|
||||
verifyNotification(notificationSpy, /Duplicating/, requests);
|
||||
});
|
||||
|
||||
it('does not insert duplicated component upon failure', function () {
|
||||
var server = create_sinon.server(500, this);
|
||||
clickDuplicate(0);
|
||||
server.respond();
|
||||
verifyComponents(unit, ['loc_1', 'loc_2']);
|
||||
});
|
||||
// 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');
|
||||
});
|
||||
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'
|
||||
})
|
||||
});
|
||||
});
|
||||
it('sends the correct JSON to the server', function () {
|
||||
var requests = create_sinon.requests(this);
|
||||
clickNewComponent();
|
||||
verifyJSON(requests, '{"category":"discussion","type":"discussion","parent_locator":"unit_locator"}');
|
||||
});
|
||||
|
||||
it('inserts new component at end', function () {
|
||||
var requests = create_sinon.requests(this);
|
||||
clickNewComponent();
|
||||
create_sinon.respondWithJson(requests, {"locator": "new_item"});
|
||||
verifyComponents(unit, ['loc_1', 'loc_2', 'new_item']);
|
||||
});
|
||||
|
||||
it('shows a notification while creating', function () {
|
||||
var notificationSpy = spyOnConstructor(NotificationView, "Mini", ["show", "hide"]);
|
||||
notificationSpy.show.andReturn(notificationSpy);
|
||||
var requests = create_sinon.requests(this);
|
||||
clickNewComponent();
|
||||
verifyNotification(notificationSpy, /Adding/, requests);
|
||||
});
|
||||
|
||||
it('does not insert duplicated component upon failure', function () {
|
||||
var server = create_sinon.server(500, this);
|
||||
clickNewComponent();
|
||||
server.respond();
|
||||
verifyComponents(unit, ['loc_1', 'loc_2']);
|
||||
});
|
||||
afterEach(function () {
|
||||
edit_helpers.uninstallMockXBlock();
|
||||
});
|
||||
describe("Disabled edit/publish links during ajax call", function() {
|
||||
var unit,
|
||||
link,
|
||||
draft_states = [
|
||||
{
|
||||
state: "draft",
|
||||
selector: ".publish-draft"
|
||||
},
|
||||
{
|
||||
state: "public",
|
||||
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']
|
||||
})
|
||||
|
||||
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 () {
|
||||
initialize(this);
|
||||
clickDuplicate(0);
|
||||
edit_helpers.verifyXBlockRequest(requests, {
|
||||
"duplicate_source_locator": "loc_1",
|
||||
"parent_locator": "unit_locator"
|
||||
});
|
||||
// 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() {
|
||||
runs(function(){
|
||||
spyOn($, "ajax").andCallThrough();
|
||||
spyOn($.fn, 'addClass').andCallThrough();
|
||||
spyOn($.fn, 'removeClass').andCallThrough();
|
||||
link = $(draft_state['selector']);
|
||||
it('inserts duplicated component immediately after source upon success', function () {
|
||||
initialize(this);
|
||||
clickDuplicate(0);
|
||||
create_sinon.respondWithJson(requests, {"locator": "duplicated_item"});
|
||||
verifyComponents(unitView, ['loc_1', 'duplicated_item', 'loc_2']);
|
||||
});
|
||||
|
||||
it('inserts duplicated component at end if source at end', function () {
|
||||
initialize(this);
|
||||
clickDuplicate(1);
|
||||
create_sinon.respondWithJson(requests, {"locator": "duplicated_item"});
|
||||
verifyComponents(unitView, ['loc_1', 'loc_2', 'duplicated_item']);
|
||||
});
|
||||
|
||||
it('shows a notification while duplicating', function () {
|
||||
var notificationSpy = edit_helpers.createNotificationSpy();
|
||||
initialize(this);
|
||||
clickDuplicate(0);
|
||||
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 () {
|
||||
initialize(this);
|
||||
clickDuplicate(0);
|
||||
create_sinon.respondWithError(requests);
|
||||
verifyComponents(unitView, ['loc_1', 'loc_2']);
|
||||
});
|
||||
});
|
||||
|
||||
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 () {
|
||||
initialize(this);
|
||||
clickNewComponent();
|
||||
edit_helpers.verifyXBlockRequest(requests, {
|
||||
"category": "discussion",
|
||||
"type": "discussion",
|
||||
"parent_locator": "unit_locator"
|
||||
});
|
||||
});
|
||||
|
||||
it('inserts new component at end', function () {
|
||||
initialize(this);
|
||||
clickNewComponent();
|
||||
create_sinon.respondWithJson(requests, {"locator": "new_item"});
|
||||
verifyComponents(unitView, ['loc_1', 'loc_2', 'new_item']);
|
||||
});
|
||||
|
||||
it('shows a notification while creating', function () {
|
||||
var notificationSpy = edit_helpers.createNotificationSpy();
|
||||
initialize(this);
|
||||
clickNewComponent();
|
||||
edit_helpers.verifyNotificationShowing(notificationSpy, /Adding/);
|
||||
create_sinon.respondWithJson(requests, {"locator": "new_item"});
|
||||
edit_helpers.verifyNotificationHidden(notificationSpy);
|
||||
});
|
||||
|
||||
it('does not insert new component upon failure', function () {
|
||||
initialize(this);
|
||||
clickNewComponent();
|
||||
create_sinon.respondWithError(requests);
|
||||
verifyComponents(unitView, ['loc_1', 'loc_2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Disabled edit/publish links during ajax call", function() {
|
||||
var link, i,
|
||||
draft_states = [
|
||||
{
|
||||
state: "draft",
|
||||
selector: ".publish-draft"
|
||||
},
|
||||
{
|
||||
state: "public",
|
||||
selector: ".create-draft"
|
||||
}
|
||||
];
|
||||
|
||||
function test_link_disabled_during_ajax_call(draft_state) {
|
||||
it("re-enables the " + draft_state.selector + " link once the ajax call returns", function() {
|
||||
initialize(this);
|
||||
link = $(draft_state.selector);
|
||||
expect(link).not.toHaveClass('is-disabled');
|
||||
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
|
||||
expect($.ajax).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
};
|
||||
for (var i = 0; i < draft_states.length; i++) {
|
||||
test_link_disabled_during_ajax_call(draft_states[i]);
|
||||
};
|
||||
for (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
|
||||
var mockXBlockEditorHtml;
|
||||
|
||||
beforeEach(function () {
|
||||
edit_helpers.installMockXBlock(mockSaveResponse);
|
||||
edit_helpers.installMockXBlock();
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
define(["sinon"], function(sinon) {
|
||||
define(["sinon", "underscore"], function(sinon, _) {
|
||||
var fakeServer, fakeRequests, respondWithJson, respondWithError;
|
||||
|
||||
/* These utility methods are used by Jasmine tests to create a mock server or
|
||||
@@ -46,14 +46,18 @@ define(["sinon"], function(sinon) {
|
||||
};
|
||||
|
||||
respondWithJson = function(requests, jsonResponse, requestIndex) {
|
||||
requestIndex = requestIndex || requests.length - 1;
|
||||
if (_.isUndefined(requestIndex)) {
|
||||
requestIndex = requests.length - 1;
|
||||
}
|
||||
requests[requestIndex].respond(200,
|
||||
{ "Content-Type": "application/json" },
|
||||
JSON.stringify(jsonResponse));
|
||||
};
|
||||
|
||||
respondWithError = function(requests, requestIndex) {
|
||||
requestIndex = requestIndex || requests.length - 1;
|
||||
if (_.isUndefined(requestIndex)) {
|
||||
requestIndex = requests.length - 1;
|
||||
}
|
||||
requests[requestIndex].respond(500,
|
||||
{ "Content-Type": "application/json" },
|
||||
JSON.stringify({ }));
|
||||
|
||||
@@ -2,22 +2,14 @@
|
||||
* Provides helper methods for invoking Studio editors in Jasmine tests.
|
||||
*/
|
||||
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"],
|
||||
function($, _, create_sinon, modal_helpers, EditXBlockModal) {
|
||||
"js/views/modals/edit_xblock", "js/collections/component_template",
|
||||
"xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
|
||||
function($, _, create_sinon, modal_helpers, EditXBlockModal, ComponentTemplates) {
|
||||
|
||||
var editorTemplate = readFixtures('metadata-editor.underscore'),
|
||||
numberEntryTemplate = readFixtures('metadata-number-entry.underscore'),
|
||||
stringEntryTemplate = readFixtures('metadata-string-entry.underscore'),
|
||||
editXBlockModalTemplate = readFixtures('edit-xblock-modal.underscore'),
|
||||
editorModeButtonTemplate = readFixtures('editor-mode-button.underscore'),
|
||||
installMockXBlock,
|
||||
uninstallMockXBlock,
|
||||
installMockXModule,
|
||||
uninstallMockXModule,
|
||||
installEditTemplates,
|
||||
showEditModal;
|
||||
var installMockXBlock, uninstallMockXBlock, installMockXModule, uninstallMockXModule,
|
||||
mockComponentTemplates, installEditTemplates, showEditModal, verifyXBlockRequest;
|
||||
|
||||
installMockXBlock = function(mockResult) {
|
||||
installMockXBlock = function() {
|
||||
window.MockXBlock = function(runtime, element) {
|
||||
return {
|
||||
runtime: runtime
|
||||
@@ -41,17 +33,52 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
|
||||
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) {
|
||||
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
|
||||
appendSetFixtures($("<script>", { id: "edit-xblock-modal-tpl", type: "text/template" }).text(editXBlockModalTemplate));
|
||||
appendSetFixtures($("<script>", { id: "editor-mode-button-tpl", type: "text/template" }).text(editorModeButtonTemplate));
|
||||
modal_helpers.installTemplate('edit-xblock-modal');
|
||||
modal_helpers.installTemplate('editor-mode-button');
|
||||
|
||||
// Add templates needed by the settings editor
|
||||
appendSetFixtures($("<script>", {id: "metadata-editor-tpl", type: "text/template"}).text(editorTemplate));
|
||||
appendSetFixtures($("<script>", {id: "metadata-number-entry", type: "text/template"}).text(numberEntryTemplate));
|
||||
appendSetFixtures($("<script>", {id: "metadata-string-entry", type: "text/template"}).text(stringEntryTemplate));
|
||||
modal_helpers.installTemplate('metadata-editor');
|
||||
modal_helpers.installTemplate('metadata-number-entry');
|
||||
modal_helpers.installTemplate('metadata-string-entry');
|
||||
};
|
||||
|
||||
showEditModal = function(requests, xblockElement, model, mockHtml, options) {
|
||||
@@ -64,12 +91,22 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
|
||||
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, {
|
||||
'installMockXBlock': installMockXBlock,
|
||||
'uninstallMockXBlock': uninstallMockXBlock,
|
||||
'installMockXModule': installMockXModule,
|
||||
'uninstallMockXModule': uninstallMockXModule,
|
||||
'mockComponentTemplates': mockComponentTemplates,
|
||||
'installEditTemplates': installEditTemplates,
|
||||
'showEditModal': showEditModal
|
||||
'showEditModal': showEditModal,
|
||||
'verifyXBlockRequest': verifyXBlockRequest
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
*/
|
||||
define(["jquery", "js/spec_helpers/view_helpers"],
|
||||
function($, view_helpers) {
|
||||
var basicModalTemplate = readFixtures('basic-modal.underscore'),
|
||||
modalButtonTemplate = readFixtures('modal-button.underscore'),
|
||||
feedbackTemplate = readFixtures('system-feedback.underscore'),
|
||||
installModalTemplates,
|
||||
var installModalTemplates,
|
||||
getModalElement,
|
||||
isShowingModal,
|
||||
hideModalIfShowing,
|
||||
@@ -15,8 +12,8 @@ define(["jquery", "js/spec_helpers/view_helpers"],
|
||||
|
||||
installModalTemplates = function(append) {
|
||||
view_helpers.installViewTemplates(append);
|
||||
appendSetFixtures($("<script>", { id: "basic-modal-tpl", type: "text/template" }).text(basicModalTemplate));
|
||||
appendSetFixtures($("<script>", { id: "modal-button-tpl", type: "text/template" }).text(modalButtonTemplate));
|
||||
view_helpers.installTemplate('basic-modal');
|
||||
view_helpers.installTemplate('modal-button');
|
||||
};
|
||||
|
||||
getModalElement = function(modal) {
|
||||
|
||||
@@ -1,20 +1,49 @@
|
||||
/**
|
||||
* Provides helper methods for invoking Studio modal windows in Jasmine tests.
|
||||
*/
|
||||
define(["jquery"],
|
||||
function($) {
|
||||
var feedbackTemplate = readFixtures('system-feedback.underscore'),
|
||||
installViewTemplates;
|
||||
define(["jquery", "js/views/feedback_notification", "js/spec_helpers/create_sinon"],
|
||||
function($, NotificationView, create_sinon) {
|
||||
var installTemplate, installViewTemplates, createNotificationSpy, verifyNotificationShowing,
|
||||
verifyNotificationHidden;
|
||||
|
||||
installViewTemplates = function(append) {
|
||||
if (append) {
|
||||
appendSetFixtures($("<script>", { id: "system-feedback-tpl", type: "text/template" }).text(feedbackTemplate));
|
||||
installTemplate = function(templateName, isFirst) {
|
||||
var template = readFixtures(templateName + '.underscore'),
|
||||
templateId = templateName + '-tpl';
|
||||
if (isFirst) {
|
||||
setFixtures($("<script>", { id: templateId, type: "text/template" }).text(template));
|
||||
} 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 {
|
||||
'installViewTemplates': installViewTemplates
|
||||
'installTemplate': installTemplate,
|
||||
'installViewTemplates': installViewTemplates,
|
||||
'createNotificationSpy': createNotificationSpy,
|
||||
'verifyNotificationShowing': verifyNotificationShowing,
|
||||
'verifyNotificationHidden': verifyNotificationHidden
|
||||
};
|
||||
});
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
* getUpdateUrl: a utility method that returns the xblock update URL, appending
|
||||
* the location if passed in.
|
||||
*/
|
||||
define([], function () {
|
||||
define(["underscore"], function (_) {
|
||||
var urlRoot = '/xblock';
|
||||
|
||||
var getUpdateUrl = function (locator) {
|
||||
if (locator === undefined) {
|
||||
if (_.isUndefined(locator)) {
|
||||
return urlRoot;
|
||||
}
|
||||
else {
|
||||
|
||||
20
cms/static/js/utils/templates.js
Normal file
20
cms/static/js/utils/templates.js
Normal file
@@ -0,0 +1,20 @@
|
||||
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
|
||||
};
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
define(["jquery", "underscore", "backbone", "js/utils/handle_iframe_binding"],
|
||||
function ($, _, Backbone, IframeUtils) {
|
||||
define(["jquery", "underscore", "backbone", "gettext", "js/utils/handle_iframe_binding", "js/utils/templates",
|
||||
"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 functionality includes:
|
||||
@@ -60,16 +61,60 @@ define(["jquery", "underscore", "backbone", "js/utils/handle_iframe_binding"],
|
||||
$('.ui-loading').hide();
|
||||
},
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param {jQuery} element: the element to be disabled.
|
||||
* @param operation: the operation during whose duration the
|
||||
* element should be disabled. The operation should return
|
||||
* a jquery promise.
|
||||
* a JQuery promise.
|
||||
*/
|
||||
disableElementWhileRunning: function(element, operation) {
|
||||
element.addClass("is-disabled");
|
||||
operation().always(function() {
|
||||
return operation().always(function() {
|
||||
element.removeClass("is-disabled");
|
||||
});
|
||||
},
|
||||
@@ -80,12 +125,38 @@ define(["jquery", "underscore", "backbone", "js/utils/handle_iframe_binding"],
|
||||
* @returns The loaded template.
|
||||
*/
|
||||
loadTemplate: function(name) {
|
||||
var templateSelector = "#" + name + "-tpl",
|
||||
templateText = $(templateSelector).text();
|
||||
if (!templateText) {
|
||||
console.error("Failed to load " + name + " template");
|
||||
}
|
||||
return _.template(templateText);
|
||||
return TemplateUtils.loadTemplate(name);
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the relative position that the element is scrolled from the top of the view port.
|
||||
* @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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
74
cms/static/js/views/components/add_xblock.js
Normal file
74
cms/static/js/views/components/add_xblock.js
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 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…'),
|
||||
_.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();
|
||||
13
cms/static/js/views/components/add_xblock_button.js
Normal file
13
cms/static/js/views/components/add_xblock_button.js
Normal file
@@ -0,0 +1,13 @@
|
||||
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();
|
||||
19
cms/static/js/views/components/add_xblock_menu.js
Normal file
19
cms/static/js/views/components/add_xblock_menu.js
Normal file
@@ -0,0 +1,19 @@
|
||||
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();
|
||||
@@ -1,10 +1,13 @@
|
||||
define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", "js/views/feedback_notification"],
|
||||
function ($, _, XBlockView, ModuleUtils, gettext, NotificationView) {
|
||||
var reorderableClass = '.reorderable-container',
|
||||
studioXBlockWrapperClass = '.studio-xblock-wrapper';
|
||||
|
||||
var ContainerView = XBlockView.extend({
|
||||
|
||||
xblockReady: function () {
|
||||
XBlockView.prototype.xblockReady.call(this);
|
||||
var verticalContainer = this.$('.vertical-container'),
|
||||
var reorderableContainer = this.$(reorderableClass),
|
||||
alreadySortable = this.$('.ui-sortable'),
|
||||
newParent,
|
||||
oldParent,
|
||||
@@ -12,13 +15,13 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
|
||||
|
||||
alreadySortable.sortable("destroy");
|
||||
|
||||
verticalContainer.sortable({
|
||||
reorderableContainer.sortable({
|
||||
handle: '.drag-handle',
|
||||
|
||||
stop: function (event, ui) {
|
||||
var saving, hideSaving, removeFromParent;
|
||||
|
||||
if (oldParent === undefined) {
|
||||
if (_.isUndefined(oldParent)) {
|
||||
// If no actual change occurred,
|
||||
// oldParent will never have been set.
|
||||
return;
|
||||
@@ -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
|
||||
// was originally in (the case of a move within the same 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) {
|
||||
// Move to a new container (the addition part).
|
||||
newParent = parent;
|
||||
@@ -69,8 +72,8 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
|
||||
placeholder: 'component-placeholder',
|
||||
forcePlaceholderSize: true,
|
||||
axis: 'y',
|
||||
items: '> .vertical-element',
|
||||
connectWith: ".vertical-container",
|
||||
items: '> .is-draggable',
|
||||
connectWith: reorderableClass,
|
||||
tolerance: "pointer"
|
||||
|
||||
});
|
||||
@@ -79,10 +82,10 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
|
||||
reorder: function (targetParent, successCallback) {
|
||||
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.
|
||||
children = targetParent.find('.wrapper-xblock').filter(function () {
|
||||
var parent = $(this).parent().closest('.wrapper-xblock');
|
||||
children = targetParent.find(studioXBlockWrapperClass).filter(function () {
|
||||
var parent = $(this).parent().closest(studioXBlockWrapperClass);
|
||||
return parent.data('locator') === targetParent.data('locator');
|
||||
});
|
||||
|
||||
@@ -107,7 +110,10 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
refresh: function() {
|
||||
this.$(reorderableClass).sortable('refresh');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
define(["js/views/baseview", "underscore", "underscore.string", "jquery"], function(BaseView, _, str, $) {
|
||||
var SystemFeedback = BaseView.extend({
|
||||
options: {
|
||||
title: "",
|
||||
message: "",
|
||||
intent: null, // "warning", "confirmation", "error", "announcement", "step-required", etc
|
||||
type: null, // "alert", "notification", or "prompt": set by subclass
|
||||
shown: true, // is this view currently being shown?
|
||||
icon: true, // should we render an icon related to the message intent?
|
||||
closeIcon: true, // should we render a close button in the top right corner?
|
||||
minShown: 0, // length of time after this view has been shown before it can be hidden (milliseconds)
|
||||
maxShown: Infinity // length of time after this view has been shown before it will be automatically hidden (milliseconds)
|
||||
define(["jquery", "underscore", "underscore.string", "backbone", "js/utils/templates"],
|
||||
function($, _, str, Backbone, TemplateUtils) {
|
||||
var SystemFeedback = Backbone.View.extend({
|
||||
options: {
|
||||
title: "",
|
||||
message: "",
|
||||
intent: null, // "warning", "confirmation", "error", "announcement", "step-required", etc
|
||||
type: null, // "alert", "notification", or "prompt": set by subclass
|
||||
shown: true, // is this view currently being shown?
|
||||
icon: true, // should we render an icon related to the message intent?
|
||||
closeIcon: true, // should we render a close button in the top right corner?
|
||||
minShown: 0, // length of time after this view has been shown before it can be hidden (milliseconds)
|
||||
maxShown: Infinity // length of time after this view has been shown before it will be automatically hidden (milliseconds)
|
||||
|
||||
/* Could also have an "actions" hash: here is an example demonstrating
|
||||
the expected structure. For each action, by default the framework
|
||||
@@ -38,100 +39,108 @@ define(["js/views/baseview", "underscore", "underscore.string", "jquery"], funct
|
||||
]
|
||||
}
|
||||
*/
|
||||
},
|
||||
initialize: function() {
|
||||
if(!this.options.type) {
|
||||
throw "SystemFeedback: type required (given " +
|
||||
JSON.stringify(this.options) + ")";
|
||||
}
|
||||
if(!this.options.intent) {
|
||||
throw "SystemFeedback: intent required (given " +
|
||||
JSON.stringify(this.options) + ")";
|
||||
}
|
||||
this.template = this.loadTemplate("system-feedback");
|
||||
this.setElement($("#page-"+this.options.type));
|
||||
// handle single "secondary" action
|
||||
if (this.options.actions && this.options.actions.secondary &&
|
||||
!_.isArray(this.options.actions.secondary)) {
|
||||
this.options.actions.secondary = [this.options.actions.secondary];
|
||||
}
|
||||
return this;
|
||||
},
|
||||
// public API: show() and hide()
|
||||
show: function() {
|
||||
clearTimeout(this.hideTimeout);
|
||||
this.options.shown = true;
|
||||
this.shownAt = new Date();
|
||||
this.render();
|
||||
if($.isNumeric(this.options.maxShown)) {
|
||||
this.hideTimeout = setTimeout(_.bind(this.hide, this),
|
||||
this.options.maxShown);
|
||||
}
|
||||
return this;
|
||||
},
|
||||
hide: function() {
|
||||
if(this.shownAt && $.isNumeric(this.options.minShown) &&
|
||||
this.options.minShown > new Date() - this.shownAt)
|
||||
{
|
||||
},
|
||||
|
||||
initialize: function() {
|
||||
if (!this.options.type) {
|
||||
throw "SystemFeedback: type required (given " +
|
||||
JSON.stringify(this.options) + ")";
|
||||
}
|
||||
if (!this.options.intent) {
|
||||
throw "SystemFeedback: intent required (given " +
|
||||
JSON.stringify(this.options) + ")";
|
||||
}
|
||||
this.template = TemplateUtils.loadTemplate("system-feedback");
|
||||
this.setElement($("#page-" + this.options.type));
|
||||
// handle single "secondary" action
|
||||
if (this.options.actions && this.options.actions.secondary &&
|
||||
!_.isArray(this.options.actions.secondary)) {
|
||||
this.options.actions.secondary = [this.options.actions.secondary];
|
||||
}
|
||||
return this;
|
||||
},
|
||||
|
||||
// public API: show() and hide()
|
||||
show: function() {
|
||||
clearTimeout(this.hideTimeout);
|
||||
this.hideTimeout = setTimeout(_.bind(this.hide, this),
|
||||
this.options.minShown - (new Date() - this.shownAt));
|
||||
} else {
|
||||
this.options.shown = false;
|
||||
delete this.shownAt;
|
||||
this.options.shown = true;
|
||||
this.shownAt = new Date();
|
||||
this.render();
|
||||
if ($.isNumeric(this.options.maxShown)) {
|
||||
this.hideTimeout = setTimeout(_.bind(this.hide, this),
|
||||
this.options.maxShown);
|
||||
}
|
||||
return this;
|
||||
},
|
||||
|
||||
hide: function() {
|
||||
if (this.shownAt && $.isNumeric(this.options.minShown) &&
|
||||
this.options.minShown > new Date() - this.shownAt) {
|
||||
clearTimeout(this.hideTimeout);
|
||||
this.hideTimeout = setTimeout(_.bind(this.hide, this),
|
||||
this.options.minShown - (new Date() - this.shownAt));
|
||||
} else {
|
||||
this.options.shown = false;
|
||||
delete this.shownAt;
|
||||
this.render();
|
||||
}
|
||||
return this;
|
||||
},
|
||||
|
||||
// the rest of the API should be considered semi-private
|
||||
events: {
|
||||
"click .action-close": "hide",
|
||||
"click .action-primary": "primaryClick",
|
||||
"click .action-secondary": "secondaryClick"
|
||||
},
|
||||
|
||||
render: function() {
|
||||
// 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
|
||||
// use a singleton approach.
|
||||
var singleton = SystemFeedback["active_" + this.options.type];
|
||||
if (singleton && singleton !== this) {
|
||||
singleton.stopListening();
|
||||
singleton.undelegateEvents();
|
||||
}
|
||||
this.$el.html(this.template(this.options));
|
||||
SystemFeedback["active_" + this.options.type] = this;
|
||||
return this;
|
||||
},
|
||||
|
||||
primaryClick: function(event) {
|
||||
var actions, primary;
|
||||
actions = this.options.actions;
|
||||
if (!actions) { return; }
|
||||
primary = actions.primary;
|
||||
if (!primary) { return; }
|
||||
if (primary.preventDefault !== false) {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (primary.click) {
|
||||
primary.click.call(event.target, this, event);
|
||||
}
|
||||
},
|
||||
|
||||
secondaryClick: function(event) {
|
||||
var actions, secondaryList, secondary, i;
|
||||
actions = this.options.actions;
|
||||
if (!actions) { return; }
|
||||
secondaryList = actions.secondary;
|
||||
if (!secondaryList) { return; }
|
||||
// which secondary action was clicked?
|
||||
i = 0; // default to the first secondary action (easier for testing)
|
||||
if (event && event.target) {
|
||||
i = _.indexOf(this.$(".action-secondary"), event.target);
|
||||
}
|
||||
secondary = secondaryList[i];
|
||||
if (secondary.preventDefault !== false) {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (secondary.click) {
|
||||
secondary.click.call(event.target, this, event);
|
||||
}
|
||||
}
|
||||
return this;
|
||||
},
|
||||
// the rest of the API should be considered semi-private
|
||||
events: {
|
||||
"click .action-close": "hide",
|
||||
"click .action-primary": "primaryClick",
|
||||
"click .action-secondary": "secondaryClick"
|
||||
},
|
||||
render: function() {
|
||||
// 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
|
||||
// use a singleton approach.
|
||||
var singleton = SystemFeedback["active_"+this.options.type];
|
||||
if(singleton && singleton !== this) {
|
||||
singleton.stopListening();
|
||||
singleton.undelegateEvents();
|
||||
}
|
||||
this.$el.html(this.template(this.options));
|
||||
SystemFeedback["active_"+this.options.type] = this;
|
||||
return this;
|
||||
},
|
||||
primaryClick: function(event) {
|
||||
var actions = this.options.actions;
|
||||
if(!actions) { return; }
|
||||
var primary = actions.primary;
|
||||
if(!primary) { return; }
|
||||
if(primary.preventDefault !== false) {
|
||||
event.preventDefault();
|
||||
}
|
||||
if(primary.click) {
|
||||
primary.click.call(event.target, this, event);
|
||||
}
|
||||
},
|
||||
secondaryClick: function(event) {
|
||||
var actions = this.options.actions;
|
||||
if(!actions) { return; }
|
||||
var secondaryList = actions.secondary;
|
||||
if(!secondaryList) { return; }
|
||||
// which secondary action was clicked?
|
||||
var i = 0; // default to the first secondary action (easier for testing)
|
||||
if(event && event.target) {
|
||||
i = _.indexOf(this.$(".action-secondary"), event.target);
|
||||
}
|
||||
var secondary = secondaryList[i];
|
||||
if(secondary.preventDefault !== false) {
|
||||
event.preventDefault();
|
||||
}
|
||||
if(secondary.click) {
|
||||
secondary.click.call(event.target, this, event);
|
||||
}
|
||||
}
|
||||
});
|
||||
return SystemFeedback;
|
||||
});
|
||||
return SystemFeedback;
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* XBlockContainerView is used to display an xblock which has children, and allows the
|
||||
* user to interact with the children.
|
||||
* XBlockContainerPage is used to display Studio's container page for an xblock which has 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"],
|
||||
function ($, _, gettext, NotificationView, PromptView, BaseView, ContainerView, XBlockView, EditXBlockModal, XBlockInfo) {
|
||||
|
||||
var XBlockContainerView = BaseView.extend({
|
||||
define(["jquery", "underscore", "gettext", "js/views/feedback_notification",
|
||||
"js/views/baseview", "js/views/container", "js/views/xblock", "js/views/components/add_xblock",
|
||||
"js/views/modals/edit_xblock", "js/models/xblock_info"],
|
||||
function ($, _, gettext, NotificationView, BaseView, ContainerView, XBlockView, AddXBlockComponent,
|
||||
EditXBlockModal, XBlockInfo) {
|
||||
var XBlockContainerPage = BaseView.extend({
|
||||
// takes XBlockInfo as a model
|
||||
|
||||
view: 'container_preview',
|
||||
@@ -39,7 +41,8 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
|
||||
success: function(xblock) {
|
||||
if (xblockView.hasChildXBlocks()) {
|
||||
xblockView.$el.removeClass('is-hidden');
|
||||
self.addButtonActions(xblockView.$el);
|
||||
self.renderAddXBlockComponents();
|
||||
self.onXBlockRefresh(xblockView);
|
||||
} else {
|
||||
noContentElement.removeClass('is-hidden');
|
||||
}
|
||||
@@ -50,137 +53,176 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
|
||||
},
|
||||
|
||||
findXBlockElement: function(target) {
|
||||
return $(target).closest('[data-locator]');
|
||||
return $(target).closest('.studio-xblock-wrapper');
|
||||
},
|
||||
|
||||
getURLRoot: function() {
|
||||
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) {
|
||||
var self = this;
|
||||
element.find('.edit-button').click(function(event) {
|
||||
var modal,
|
||||
target = event.target,
|
||||
xblockElement = self.findXBlockElement(target);
|
||||
event.preventDefault();
|
||||
modal = new EditXBlockModal({ });
|
||||
modal.edit(xblockElement, self.model,
|
||||
{
|
||||
refresh: function(xblockInfo) {
|
||||
self.refreshXBlock(xblockInfo, xblockElement);
|
||||
}
|
||||
});
|
||||
self.editComponent(self.findXBlockElement(event.target));
|
||||
});
|
||||
element.find('.duplicate-button').click(function(event) {
|
||||
event.preventDefault();
|
||||
self.duplicateComponent(
|
||||
self.findXBlockElement(event.target)
|
||||
);
|
||||
self.duplicateComponent(self.findXBlockElement(event.target));
|
||||
});
|
||||
element.find('.delete-button').click(function(event) {
|
||||
event.preventDefault();
|
||||
self.deleteComponent(
|
||||
self.findXBlockElement(event.target)
|
||||
);
|
||||
self.deleteComponent(self.findXBlockElement(event.target));
|
||||
});
|
||||
},
|
||||
|
||||
editComponent: function(xblockElement) {
|
||||
var self = this,
|
||||
modal = new EditXBlockModal({ });
|
||||
modal.edit(xblockElement, this.model, {
|
||||
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));
|
||||
},
|
||||
|
||||
duplicateComponent: function(xblockElement) {
|
||||
// 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,
|
||||
parentElement = self.findXBlockElement(xblockElement.parent()),
|
||||
duplicating = new NotificationView.Mini({
|
||||
title: gettext('Duplicating…')
|
||||
parent = xblockElement.parent();
|
||||
this.runOperationShowingMessage(gettext('Duplicating…'),
|
||||
function() {
|
||||
var scrollOffset = self.getScrollOffset(xblockElement),
|
||||
placeholderElement = $('<div></div>').insertAfter(xblockElement),
|
||||
parentElement = self.findXBlockElement(parent),
|
||||
requestData = {
|
||||
duplicate_source_locator: xblockElement.data('locator'),
|
||||
parent_locator: parentElement.data('locator')
|
||||
};
|
||||
return $.postJSON(self.getURLRoot(), requestData,
|
||||
_.bind(self.onNewXBlock, self, placeholderElement, scrollOffset));
|
||||
});
|
||||
|
||||
duplicating.show();
|
||||
return $.postJSON(self.getURLRoot(), {
|
||||
duplicate_source_locator: xblockElement.data('locator'),
|
||||
parent_locator: parentElement.data('locator')
|
||||
}, function(data) {
|
||||
// copy the element
|
||||
var duplicatedElement = xblockElement.clone(false);
|
||||
|
||||
// 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) {
|
||||
var self = this, deleting;
|
||||
return new PromptView.Warning({
|
||||
title: gettext('Delete this component?'),
|
||||
message: gettext('Deleting this component is permanent and cannot be undone.'),
|
||||
actions: {
|
||||
primary: {
|
||||
text: gettext('Yes, delete this component'),
|
||||
click: function(prompt) {
|
||||
prompt.hide();
|
||||
deleting = new NotificationView.Mini({
|
||||
title: gettext('Deleting…')
|
||||
});
|
||||
deleting.show();
|
||||
var self = this;
|
||||
this.confirmThenRunOperation(gettext('Delete this component?'),
|
||||
gettext('Deleting this component is permanent and cannot be undone.'),
|
||||
gettext('Yes, delete this component'),
|
||||
function() {
|
||||
self.runOperationShowingMessage(gettext('Deleting…'),
|
||||
function() {
|
||||
return $.ajax({
|
||||
type: 'DELETE',
|
||||
url:
|
||||
self.getURLRoot() + "/" +
|
||||
xblockElement.data('locator') + "?" +
|
||||
$.param({recurse: true, all_versions: true})
|
||||
url: self.getURLRoot() + "/" +
|
||||
xblockElement.data('locator') + "?" +
|
||||
$.param({recurse: true, all_versions: true})
|
||||
}).success(function() {
|
||||
deleting.hide();
|
||||
xblockElement.remove();
|
||||
});
|
||||
}
|
||||
},
|
||||
secondary: {
|
||||
text: gettext('Cancel'),
|
||||
click: function(prompt) {
|
||||
return prompt.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
}).show();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
refreshXBlockElement: function(xblockElement) {
|
||||
this.refreshXBlock(
|
||||
new XBlockInfo({
|
||||
id: xblockElement.data('locator')
|
||||
}),
|
||||
xblockElement
|
||||
);
|
||||
onNewXBlock: function(xblockElement, scrollOffset, data) {
|
||||
this.setScrollOffset(xblockElement, scrollOffset);
|
||||
xblockElement.data('locator', data.locator);
|
||||
return this.refreshXBlock(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
|
||||
// for the container xblock itself. Any child xblocks rendered inside the
|
||||
// container do not get a Backbone view. Thus, create a temporary XBlock
|
||||
// around the child element so that it can be refreshed.
|
||||
temporaryView = new XBlockView({
|
||||
el: xblockElement,
|
||||
model: xblockInfo,
|
||||
view: this.view
|
||||
// container do not get a Backbone view. Thus, create a temporary view
|
||||
// to render the content, and then replace the original element with the result.
|
||||
TemporaryXBlockView = XBlockView.extend({
|
||||
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.render({
|
||||
temporaryView = new TemporaryXBlockView({
|
||||
model: xblockInfo,
|
||||
view: 'reorderable_container_child_preview',
|
||||
el: xblockElement
|
||||
});
|
||||
return temporaryView.render({
|
||||
success: function() {
|
||||
self.onXBlockRefresh(temporaryView);
|
||||
temporaryView.unbind(); // Remove the temporary view
|
||||
self.addButtonActions(xblockElement);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return XBlockContainerView;
|
||||
return XBlockContainerPage;
|
||||
}); // end define();
|
||||
|
||||
@@ -74,12 +74,23 @@ define(["jquery", "underscore", "js/views/baseview", "xblock/runtime.v1"],
|
||||
if (!element) {
|
||||
element = this.$el;
|
||||
}
|
||||
// First render the HTML as the scripts might depend upon it
|
||||
element.html(html);
|
||||
// Now asynchronously add the resources to the page
|
||||
|
||||
// Render the HTML first as the scripts might depend upon it, and then
|
||||
// asynchronously add the resources to the page.
|
||||
this.updateHtml(element, html);
|
||||
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
|
||||
* process so a promise is returned.
|
||||
|
||||
@@ -190,7 +190,7 @@
|
||||
@include transition(all $tmg-f3 ease-in-out 0s);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
background: $black-t0;
|
||||
background: $black-t1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
@@ -676,11 +676,6 @@
|
||||
// prompt showing
|
||||
&.prompt-is-shown {
|
||||
|
||||
.wrapper-view {
|
||||
-webkit-filter: blur(($baseline/10)) grayscale(25%);
|
||||
filter: blur(($baseline/10)) grayscale(25%);
|
||||
}
|
||||
|
||||
.wrapper-prompt.is-shown {
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
@@ -694,11 +689,6 @@
|
||||
// prompt hiding
|
||||
&.prompt-is-hiding {
|
||||
|
||||
.wrapper-view {
|
||||
-webkit-filter: blur(($baseline/10)) grayscale(25%);
|
||||
filter: blur(($baseline/10)) grayscale(25%);
|
||||
}
|
||||
|
||||
.wrapper-prompt {
|
||||
|
||||
.prompt {
|
||||
|
||||
@@ -48,13 +48,18 @@
|
||||
// UI: xblocks - calls-to-action
|
||||
.wrapper-xblock .header-actions {
|
||||
@extend %actions-header;
|
||||
|
||||
.action-button [class^="icon-"] {
|
||||
font-style: normal;
|
||||
}
|
||||
}
|
||||
|
||||
// UI: xblock is collapsible
|
||||
.wrapper-xblock.is-collapsible, .wrapper-xblock.xblock-type-container {
|
||||
.wrapper-xblock.is-collapsible,
|
||||
.wrapper-xblock.xblock-type-container {
|
||||
|
||||
[class^="icon-"] {
|
||||
font-style: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.expand-collapse {
|
||||
|
||||
@@ -116,40 +116,6 @@ body.view-container .content-primary {
|
||||
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 {
|
||||
@extend %wrap-xblock;
|
||||
|
||||
@@ -165,18 +131,17 @@ body.view-container .content-primary {
|
||||
// CASE: nesting level xblock rendering
|
||||
&.level-nesting {
|
||||
@include transition(all $tmg-f2 linear 0s);
|
||||
border: none;
|
||||
border: 1px solid $gray-l3;
|
||||
padding-bottom: $baseline;
|
||||
box-shadow: none;
|
||||
|
||||
&:hover {
|
||||
background-color: $gray-l6;
|
||||
box-shadow: 0 0 1px $shadow-d2 inset;
|
||||
// min-height to allow drop when empty
|
||||
.reorderable-container {
|
||||
min-height: $baseline;
|
||||
}
|
||||
|
||||
.xblock-header {
|
||||
@include ui-flexbox();
|
||||
margin-bottom: ($baseline/2);
|
||||
margin-bottom: 0;
|
||||
border-bottom: none;
|
||||
background: none;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// studio - views - unit
|
||||
// ====================
|
||||
|
||||
body.course.unit,.view-unit {
|
||||
body.course.unit,
|
||||
.view-unit {
|
||||
|
||||
.main-wrapper {
|
||||
margin-top: ($baseline*2);
|
||||
@@ -91,286 +92,18 @@ 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 {
|
||||
margin-top: ($baseline*1.25);
|
||||
box-shadow: none;
|
||||
border-top: 5px solid $red-l1;
|
||||
|
||||
.copy,
|
||||
.title {
|
||||
color: $white;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
margin-top: ($baseline*1.25);
|
||||
box-shadow: none;
|
||||
border-top: 5px solid $red-l1;
|
||||
|
||||
.copy,
|
||||
.title {
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
@@ -1444,3 +1177,260 @@ body.unit .component.editing {
|
||||
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 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
% if not xblock_context['read_only']:
|
||||
<span data-tooltip="${_("Drag to reorder")}" class="drag-handle"></span>
|
||||
% endif
|
||||
<span data-tooltip="${_("Drag to reorder")}" class="drag-handle action"></span>
|
||||
${preview}
|
||||
|
||||
@@ -31,15 +31,18 @@ main_xblock_info = {
|
||||
%>
|
||||
<script type='text/javascript'>
|
||||
require(["domReady!", "jquery", "js/models/xblock_info", "js/views/pages/container",
|
||||
"xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
|
||||
function(doc, $, XBlockInfo, ContainerPage) {
|
||||
"js/collections/component_template", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
|
||||
function(doc, $, XBlockInfo, ContainerPage, ComponentTemplates) {
|
||||
var view, mainXBlockInfo;
|
||||
|
||||
var templates = new ComponentTemplates(${component_templates | n}, {parse: true});
|
||||
|
||||
mainXBlockInfo = new XBlockInfo(${json.dumps(main_xblock_info) | n});
|
||||
|
||||
view = new ContainerPage({
|
||||
el: $('#content'),
|
||||
model: mainXBlockInfo
|
||||
model: mainXBlockInfo,
|
||||
templates: templates
|
||||
});
|
||||
view.render();
|
||||
});
|
||||
@@ -80,7 +83,7 @@ main_xblock_info = {
|
||||
<section class="content-area">
|
||||
|
||||
<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>
|
||||
<div class="no-container-content is-hidden">
|
||||
<p>${_("This page has no content yet.")}</p>
|
||||
|
||||
@@ -18,10 +18,10 @@ from contentstore.views.helpers import xblock_studio_url
|
||||
<i class="icon-arrow-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li class="action-item action-drag">
|
||||
<span data-tooltip="${_('Drag to reorder')}" class="drag-handle action"></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
% if not xblock_context['read_only']:
|
||||
<span data-tooltip="${_("Drag to reorder")}" class="drag-handle"></span>
|
||||
% endif
|
||||
</section>
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
</div>
|
||||
|
||||
% 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>
|
||||
</div>
|
||||
% else:
|
||||
|
||||
8
cms/templates/js/add-xblock-component-button.underscore
Normal file
8
cms/templates/js/add-xblock-component-button.underscore
Normal file
@@ -0,0 +1,8 @@
|
||||
<% 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>
|
||||
@@ -0,0 +1,47 @@
|
||||
<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>
|
||||
23
cms/templates/js/add-xblock-component-menu.underscore
Normal file
23
cms/templates/js/add-xblock-component-menu.underscore
Normal file
@@ -0,0 +1,23 @@
|
||||
<% 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>
|
||||
<% } %>
|
||||
5
cms/templates/js/add-xblock-component.underscore
Normal file
5
cms/templates/js/add-xblock-component.underscore
Normal file
@@ -0,0 +1,5 @@
|
||||
<div class="new-component">
|
||||
<h5><%= gettext("Add New Component") %></h5>
|
||||
<ul class="new-component-type">
|
||||
</ul>
|
||||
</div>
|
||||
@@ -4,7 +4,6 @@
|
||||
<header class="mast has-actions has-navigation">
|
||||
<h1 class="page-header">
|
||||
<small class="navigation navigation-parents">
|
||||
|
||||
<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>
|
||||
</small>
|
||||
@@ -23,7 +22,7 @@
|
||||
<section class="content-area">
|
||||
|
||||
<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>
|
||||
<div class="no-container-content is-hidden">
|
||||
<p>This page has no content yet.</p>
|
||||
@@ -37,6 +36,4 @@
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="page-notification"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,221 +2,221 @@
|
||||
|
||||
<article class="xblock-render">
|
||||
<div class="xblock" data-block-type="vertical" data-locator="locator-container">
|
||||
<div class="vert-mod">
|
||||
<ol class="vertical-container">
|
||||
<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="vert-mod">
|
||||
<ol class="vertical-container">
|
||||
<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">
|
||||
<header class="xblock-header"></header>
|
||||
|
||||
<article class="xblock-render">
|
||||
<div class="xblock" data-block-type="vertical">
|
||||
<div class="vert-mod">
|
||||
<ol class="vertical-container">
|
||||
<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"
|
||||
data-locator="locator-component-A1">
|
||||
<header class="xblock-header">
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
<li class="action-item action-edit"><a
|
||||
href="#"
|
||||
class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate"><a
|
||||
href="#"
|
||||
class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-delete"><a
|
||||
href="#"
|
||||
class="delete-button action-button"></a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
<article class="xblock-render"></article>
|
||||
</section>
|
||||
</div>
|
||||
<ol class="reorderable-container">
|
||||
<li class="studio-xblock-wrapper is-draggable" data-locator="testCourse/branch/draft/split_test/splitFFF">
|
||||
<div class="xblock" data-block-type="vertical">
|
||||
<ol class="reorderable-container">
|
||||
<li class="studio-xblock-wrapper is-draggable" data-locator="locator-group-A">
|
||||
<section class="wrapper-xblock level-nesting" data-locator="locator-group-A">
|
||||
<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">
|
||||
<div class="xblock" data-block-type="vertical">
|
||||
<ol class="reorderable-container">
|
||||
<li class="studio-xblock-wrapper is-draggable" data-locator="locator-component-A1">
|
||||
<section class="wrapper-xblock level-element"
|
||||
data-locator="locator-component-A1">
|
||||
<header class="xblock-header">
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
<li class="action-item action-edit"><a
|
||||
href="#"
|
||||
class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="vertical-element is-draggable">
|
||||
<div class="vert vert-1">
|
||||
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
|
||||
<section class="wrapper-xblock level-element"
|
||||
data-locator="locator-component-A2">
|
||||
|
||||
<header class="xblock-header">
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
<li class="action-item action-edit"><a
|
||||
href="#"
|
||||
class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate"><a
|
||||
href="#"
|
||||
class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-delete"><a
|
||||
href="#"
|
||||
class="delete-button action-button"></a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
<article class="xblock-render"></article>
|
||||
</section>
|
||||
</div>
|
||||
<li class="action-item action-duplicate"><a
|
||||
href="#"
|
||||
class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
<li class="vertical-element is-draggable">
|
||||
<div class="vert vert-2">
|
||||
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
|
||||
<section class="wrapper-xblock level-element"
|
||||
data-locator="locator-component-A3">
|
||||
<header class="xblock-header">
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
<li class="action-item action-edit"><a
|
||||
href="#"
|
||||
class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate"><a
|
||||
href="#"
|
||||
class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-delete"><a
|
||||
href="#"
|
||||
class="delete-button action-button"></a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
<article class="xblock-render"></article>
|
||||
</section>
|
||||
</div>
|
||||
<li class="action-item action-delete"><a
|
||||
href="#"
|
||||
class="delete-button action-button"></a>
|
||||
</li>
|
||||
</ol>
|
||||
<li class="action-item action-drag">
|
||||
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</li>
|
||||
<li class="vertical-element is-draggable">
|
||||
<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">
|
||||
<header class="xblock-header"></header>
|
||||
</header>
|
||||
<article class="xblock-render"></article>
|
||||
</section>
|
||||
</li>
|
||||
<li class="studio-xblock-wrapper is-draggable" data-locator="locator-component-A2">
|
||||
<section class="wrapper-xblock level-element"
|
||||
data-locator="locator-component-A2">
|
||||
|
||||
<article class="xblock-render">
|
||||
<div class="xblock" data-block-type="vertical">
|
||||
<div class="vert-mod">
|
||||
<ol class="vertical-container">
|
||||
<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"
|
||||
data-locator="locator-component-B1">
|
||||
|
||||
<header class="xblock-header">
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
<li class="action-item action-edit"><a
|
||||
href="#"
|
||||
class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate"><a
|
||||
href="#"
|
||||
class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-delete"><a
|
||||
href="#"
|
||||
class="delete-button action-button"></a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
<article class="xblock-render"></article>
|
||||
</section>
|
||||
</div>
|
||||
<header class="xblock-header">
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
<li class="action-item action-edit"><a
|
||||
href="#"
|
||||
class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="vertical-element is-draggable">
|
||||
<div class="vert vert-1">
|
||||
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
|
||||
<section class="wrapper-xblock level-element"
|
||||
data-locator="locator-component-B2">
|
||||
|
||||
<header class="xblock-header">
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
<li class="action-item action-edit"><a
|
||||
href="#"
|
||||
class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate"><a
|
||||
href="#"
|
||||
class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-delete"><a
|
||||
href="#"
|
||||
class="delete-button action-button"></a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
<article class="xblock-render"></article>
|
||||
</section>
|
||||
</div>
|
||||
<li class="action-item action-duplicate"><a
|
||||
href="#"
|
||||
class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
<li class="vertical-element is-draggable">
|
||||
<div class="vert vert-2">
|
||||
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
|
||||
<section class="wrapper-xblock level-element"
|
||||
data-locator="locator-component-B3">
|
||||
|
||||
<header class="xblock-header">
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
<li class="action-item action-edit"><a
|
||||
href="#"
|
||||
class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate"><a
|
||||
href="#"
|
||||
class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-delete"><a
|
||||
href="#"
|
||||
class="delete-button action-button"></a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
<article class="xblock-render"></article>
|
||||
</section>
|
||||
</div>
|
||||
<li class="action-item action-delete"><a
|
||||
href="#"
|
||||
class="delete-button action-button"></a>
|
||||
</li>
|
||||
</ol>
|
||||
<li class="action-item action-drag">
|
||||
<span data-tooltip="Drag to reorder" class="drag-handle action"></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</header>
|
||||
<article class="xblock-render"></article>
|
||||
</section>
|
||||
</li>
|
||||
<li class="studio-xblock-wrapper is-draggable" data-locator="locator-component-A3">
|
||||
<section class="wrapper-xblock level-element"
|
||||
data-locator="locator-component-A3">
|
||||
<header class="xblock-header">
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
<li class="action-item action-edit"><a
|
||||
href="#"
|
||||
class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate"><a
|
||||
href="#"
|
||||
class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-delete"><a
|
||||
href="#"
|
||||
class="delete-button action-button"></a>
|
||||
</li>
|
||||
<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>
|
||||
</section>
|
||||
</li>
|
||||
</ol>
|
||||
<div class="add-xblock-component new-component-item adding"></div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</li>
|
||||
<li class="studio-xblock-wrapper is-draggable" data-locator="locator-group-B">
|
||||
<section class="wrapper-xblock level-nesting" data-locator="locator-group-B">
|
||||
<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">
|
||||
<div class="xblock" data-block-type="vertical">
|
||||
<ol class="reorderable-container">
|
||||
<li class="studio-xblock-wrapper is-draggable" data-locator="locator-component-B1">
|
||||
<section class="wrapper-xblock level-element"
|
||||
data-locator="locator-component-B1">
|
||||
|
||||
<header class="xblock-header">
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
<li class="action-item action-edit"><a
|
||||
href="#"
|
||||
class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate"><a
|
||||
href="#"
|
||||
class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-delete"><a
|
||||
href="#"
|
||||
class="delete-button action-button"></a>
|
||||
</li>
|
||||
<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>
|
||||
</section>
|
||||
</li>
|
||||
<li class="studio-xblock-wrapper is-draggable" data-locator="locator-component-B2">
|
||||
<section class="wrapper-xblock level-element"
|
||||
data-locator="locator-component-B2">
|
||||
|
||||
<header class="xblock-header">
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
<li class="action-item action-edit"><a
|
||||
href="#"
|
||||
class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate"><a
|
||||
href="#"
|
||||
class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-delete"><a
|
||||
href="#"
|
||||
class="delete-button action-button"></a>
|
||||
</li>
|
||||
<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>
|
||||
</section>
|
||||
</li>
|
||||
<li class="studio-xblock-wrapper is-draggable" data-locator="locator-component-B3">
|
||||
<section class="wrapper-xblock level-element"
|
||||
data-locator="locator-component-B3">
|
||||
|
||||
<header class="xblock-header">
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
<li class="action-item action-edit"><a
|
||||
href="#"
|
||||
class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate"><a
|
||||
href="#"
|
||||
class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-delete"><a
|
||||
href="#"
|
||||
class="delete-button action-button"></a>
|
||||
</li>
|
||||
<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>
|
||||
</section>
|
||||
</li>
|
||||
</ol>
|
||||
<div class="add-xblock-component new-component-item adding"></div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -14,9 +14,7 @@
|
||||
</header>
|
||||
<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="vert-mod">
|
||||
<div class="vert vert-0" data-id="i4x://AndyA/ABT101/vertical/2758bbc495dd40d59050da15b40bd9a5">
|
||||
</div>
|
||||
</div>
|
||||
<ol class="reorderable-container">
|
||||
</ol>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
27
cms/templates/js/mock/mock-unit-page-xblock.underscore
Normal file
27
cms/templates/js/mock/mock-unit-page-xblock.underscore
Normal file
@@ -0,0 +1,27 @@
|
||||
<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>
|
||||
47
cms/templates/js/mock/mock-unit-page.underscore
Normal file
47
cms/templates/js/mock/mock-unit-page.underscore
Normal file
@@ -0,0 +1,47 @@
|
||||
<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>
|
||||
@@ -1,17 +1,19 @@
|
||||
<header class="xblock-header">
|
||||
<div class="header-details">
|
||||
<span>Mock XBlock</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
<li class="sr action-item">No Actions</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
<article class="xblock-render">
|
||||
<div class="xblock xblock-student_view xmodule_display xmodule_VerticalModule"
|
||||
data-runtime-class="PreviewRuntime" data-init="XBlockToXModuleShim" data-runtime-version="1"
|
||||
data-type="None">
|
||||
<div class="mock-updated-content">Mock Update</div>
|
||||
</div>
|
||||
</article>
|
||||
<li class="studio-xblock-wrapper is-draggable">
|
||||
<header class="xblock-header">
|
||||
<div class="header-details">
|
||||
<span>Mock XBlock</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
<li class="sr action-item">No Actions</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
<article class="xblock-render">
|
||||
<div class="xblock xblock-student_view xmodule_display xmodule_VerticalModule"
|
||||
data-runtime-class="PreviewRuntime" data-init="XBlockToXModuleShim" data-runtime-version="1"
|
||||
data-type="None">
|
||||
<div class="mock-updated-content">Mock Update</div>
|
||||
</div>
|
||||
</article>
|
||||
</li>
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
<header class="xblock-header">
|
||||
<div class="header-details">
|
||||
<span>Mock XBlock</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
<li class="sr action-item">No Actions</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
<article class="xblock-render">
|
||||
<div class="xblock xblock-student_view xmodule_display xmodule_VerticalModule"
|
||||
data-runtime-class="PreviewRuntime" data-init="XBlockToXModuleShim" data-runtime-version="1"
|
||||
data-type="None">
|
||||
<p>Mock XBlock</p>
|
||||
</div>
|
||||
</article>
|
||||
<li class="studio-xblock-wrapper is-draggable">
|
||||
<header class="xblock-header">
|
||||
<div class="header-details">
|
||||
<span>Mock XBlock</span>
|
||||
</div>
|
||||
<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">
|
||||
<div class="xblock xblock-student_view xmodule_display xmodule_VerticalModule"
|
||||
data-runtime-class="PreviewRuntime" data-init="XBlockToXModuleShim" data-runtime-version="1"
|
||||
data-type="None">
|
||||
<p>Mock XBlock</p>
|
||||
</div>
|
||||
</article>
|
||||
</li>
|
||||
|
||||
@@ -84,7 +84,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<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>
|
||||
</header>
|
||||
</section>
|
||||
@@ -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>
|
||||
</li>
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -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>
|
||||
</li>
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
39
cms/templates/studio_container_wrapper.html
Normal file
39
cms/templates/studio_container_wrapper.html
Normal file
@@ -0,0 +1,39 @@
|
||||
<%!
|
||||
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
|
||||
@@ -1,24 +0,0 @@
|
||||
<%! 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
|
||||
@@ -1,40 +1,57 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%! from django.conf import settings %>
|
||||
|
||||
% if xblock.location != xblock_context['root_xblock'].location:
|
||||
<% section_class = "level-nesting" if xblock.has_children else "level-element" %>
|
||||
<section class="wrapper-xblock ${section_class}" data-locator="${locator}" data-display-name="${xblock.display_name_with_default | h}" data-category="${xblock.category | h}">
|
||||
% if not is_root:
|
||||
% if is_reorderable:
|
||||
<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
|
||||
|
||||
<header class="xblock-header">
|
||||
<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 class="header-actions">
|
||||
<ul class="actions-list">
|
||||
% if not xblock_context['read_only']:
|
||||
% if not xblock.has_children:
|
||||
<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>
|
||||
% endif
|
||||
%if settings.FEATURES.get('ENABLE_DUPLICATE_XBLOCK_LEAF_COMPONENT'):
|
||||
<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")}</span>
|
||||
</a>
|
||||
</li>
|
||||
% endif
|
||||
%if settings.FEATURES.get('ENABLE_DELETE_XBLOCK_LEAF_COMPONENT'):
|
||||
<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")}</span>
|
||||
</a>
|
||||
</li>
|
||||
% 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>
|
||||
</div>
|
||||
@@ -43,6 +60,11 @@
|
||||
${content}
|
||||
</article>
|
||||
|
||||
% if xblock.location != xblock_context['root_xblock'].location:
|
||||
</section>
|
||||
% if not is_root:
|
||||
</section>
|
||||
% if is_reorderable:
|
||||
</li>
|
||||
% else:
|
||||
</div>
|
||||
% endif
|
||||
% endif
|
||||
|
||||
@@ -21,13 +21,12 @@ from xmodule.modulestore.django import loc_mapper
|
||||
|
||||
<%block name="jsextra">
|
||||
<script type='text/javascript'>
|
||||
require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit", "jquery.ui",
|
||||
"xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
|
||||
function(doc, $, ModuleModel, UnitEditView, ui) {
|
||||
require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit", "js/collections/component_template",
|
||||
"jquery.ui", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
|
||||
function(doc, $, ModuleModel, UnitEditView, ComponentTemplates) {
|
||||
window.unit_location_analytics = '${unit_locator}';
|
||||
|
||||
// tabs
|
||||
$('.tab-group').tabs();
|
||||
var templates = new ComponentTemplates(${component_templates | n}, {parse: true});
|
||||
|
||||
new UnitEditView({
|
||||
el: $('.main-wrapper'),
|
||||
@@ -35,7 +34,8 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
|
||||
model: new ModuleModel({
|
||||
id: '${unit_locator}',
|
||||
state: '${unit_state}'
|
||||
})
|
||||
}),
|
||||
templates: templates
|
||||
});
|
||||
|
||||
$('.new-component-template').each(function(){
|
||||
@@ -64,87 +64,8 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
|
||||
% for locator in locators:
|
||||
<li class="component" data-locator="${locator}"/>
|
||||
% 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>
|
||||
<div class="add-xblock-component new-component-item adding"></div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ from django.utils.translation import ugettext as _
|
||||
</li>
|
||||
</ul>
|
||||
</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">
|
||||
<ol>
|
||||
<li>
|
||||
@@ -306,7 +306,7 @@ from django.utils.translation import ugettext as _
|
||||
</li>
|
||||
</ul>
|
||||
</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">
|
||||
|
||||
|
||||
@@ -561,7 +561,7 @@ from django.utils.translation import ugettext as _
|
||||
</li>
|
||||
</ul>
|
||||
</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 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
|
||||
<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 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>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -61,6 +61,3 @@ This def will enumerate through a passed in subsection and list all of the units
|
||||
</li>
|
||||
</ol>
|
||||
</%def>
|
||||
|
||||
|
||||
|
||||
|
||||
30
common/lib/xmodule/xmodule/studio_editable.py
Normal file
30
common/lib/xmodule/xmodule/studio_editable.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
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,
|
||||
}))
|
||||
25
common/lib/xmodule/xmodule/tests/test_studio_editable.py
Normal file
25
common/lib/xmodule/xmodule/tests/test_studio_editable.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
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)
|
||||
65
common/lib/xmodule/xmodule/tests/test_vertical.py
Normal file
65
common/lib/xmodule/xmodule/tests/test_vertical.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
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
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.seq_module import SequenceDescriptor
|
||||
from xmodule.progress import Progress
|
||||
from xmodule.studio_editable import StudioEditableModule
|
||||
from pkg_resources import resource_string
|
||||
from copy import copy
|
||||
|
||||
@@ -14,7 +15,7 @@ class VerticalFields(object):
|
||||
has_children = True
|
||||
|
||||
|
||||
class VerticalModule(VerticalFields, XModule):
|
||||
class VerticalModule(VerticalFields, XModule, StudioEditableModule):
|
||||
''' Layout module for laying out submodules vertically.'''
|
||||
|
||||
def student_view(self, context):
|
||||
@@ -28,7 +29,9 @@ class VerticalModule(VerticalFields, XModule):
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
|
||||
@@ -8,6 +8,9 @@ from . import BASE_URL
|
||||
|
||||
from selenium.webdriver.common.action_chains import ActionChains
|
||||
|
||||
from utils import click_css, wait_for_notification
|
||||
|
||||
|
||||
class ContainerPage(PageObject):
|
||||
"""
|
||||
Container page in Studio
|
||||
@@ -45,23 +48,49 @@ class ContainerPage(PageObject):
|
||||
return self.q(css=XBlockWrapper.BODY_SELECTOR).map(
|
||||
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)
|
||||
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
|
||||
one with the target_index drag handle, unless 'after' is set to False.
|
||||
This should drag the element with the source_index drag handle BEFORE the
|
||||
one with the target_index drag handle.
|
||||
"""
|
||||
draggables = self.q(css='.drag-handle')
|
||||
source = draggables[source_index]
|
||||
target = draggables[target_index]
|
||||
action = ActionChains(self.browser)
|
||||
action.click_and_hold(source).perform() # pylint: disable=protected-access
|
||||
action.move_to_element_with_offset(
|
||||
target, 0, target.size['height'] / 2 if after else 0
|
||||
).perform() # pylint: disable=protected-access
|
||||
action.release().perform()
|
||||
# When dragging before the target element, must take into account that the placeholder
|
||||
# will appear in the place where the target used to be.
|
||||
placeholder_height = 40
|
||||
action.click_and_hold(source).move_to_element_with_offset(
|
||||
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):
|
||||
@@ -69,7 +98,7 @@ class XBlockWrapper(PageObject):
|
||||
A PageObject representing a wrapper around an XBlock child shown on the Studio container page.
|
||||
"""
|
||||
url = None
|
||||
BODY_SELECTOR = '.wrapper-xblock'
|
||||
BODY_SELECTOR = '.studio-xblock-wrapper'
|
||||
NAME_SELECTOR = '.header-details'
|
||||
|
||||
def __init__(self, browser, locator):
|
||||
|
||||
35
common/test/acceptance/pages/studio/utils.py
Normal file
35
common/test/acceptance/pages/studio/utils.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
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):
|
||||
self.group_b_item_1 = "Group B Item 1"
|
||||
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.auth_page.visit()
|
||||
@@ -79,13 +93,6 @@ class ContainerBase(UniqueCourseTest):
|
||||
container = unit.components[0].go_to_container()
|
||||
return container
|
||||
|
||||
|
||||
class DragAndDropTest(ContainerBase):
|
||||
"""
|
||||
Tests of reordering within the container page.
|
||||
"""
|
||||
__test__ = True
|
||||
|
||||
def verify_ordering(self, container, expected_orderings):
|
||||
xblocks = container.xblocks
|
||||
for expected_ordering in expected_orderings:
|
||||
@@ -101,25 +108,38 @@ class DragAndDropTest(ContainerBase):
|
||||
self.assertEqual(expected, children[idx].name)
|
||||
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.drag(source, target, after)
|
||||
action(container)
|
||||
|
||||
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()
|
||||
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):
|
||||
"""
|
||||
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]},
|
||||
{self.group_a: [self.group_a_item_1, self.group_a_item_2]},
|
||||
{self.group_b: [self.group_b_item_2, self.group_b_item_1]},
|
||||
{self.group_a: [self.group_a_item_2, self.group_a_item_1]},
|
||||
{self.group_b: [self.group_b_item_1, self.group_b_item_2]},
|
||||
{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):
|
||||
"""
|
||||
@@ -129,35 +149,157 @@ class DragAndDropTest(ContainerBase):
|
||||
{self.group_a: [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(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):
|
||||
"""
|
||||
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]},
|
||||
{self.group_a: [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_a: [self.group_b_item_1, self.group_a_item_1, self.group_a_item_2]},
|
||||
{self.group_b: [self.group_b_item_2]},
|
||||
{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):
|
||||
"""
|
||||
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]},
|
||||
{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(4, 2, expected_ordering)
|
||||
self.drag_and_verify(self.group_b_handle, self.group_a_item_1_handle, 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)
|
||||
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_empty: []}]
|
||||
self.delete_and_verify(self.group_a_item_1_action_index, expected_ordering)
|
||||
|
||||
8
lms/templates/studio_render_children_view.html
Normal file
8
lms/templates/studio_render_children_view.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<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
|
||||
@@ -1,17 +0,0 @@
|
||||
<%!
|
||||
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>
|
||||
Reference in New Issue
Block a user