Merge remote-tracking branch 'edx/master' into opaque-keys
Conflicts: cms/djangoapps/contentstore/tests/test_contentstore.py cms/djangoapps/contentstore/views/component.py cms/djangoapps/contentstore/views/item.py cms/djangoapps/contentstore/views/preview.py cms/djangoapps/contentstore/views/tests/test_container.py cms/static/js/spec/views/unit_spec.js cms/static/js/utils/module.js cms/templates/container.html cms/templates/studio_vertical_wrapper.html cms/templates/studio_xblock_wrapper.html common/djangoapps/student/views.py lms/templates/notes.html lms/templates/textannotation.html lms/templates/videoannotation.html
This commit is contained in:
@@ -5,10 +5,16 @@ These are notable changes in edx-platform. This is a rolling list of changes,
|
||||
in roughly chronological order, most recent first. Add your entries at or near
|
||||
the top. Include a label indicating the component affected.
|
||||
|
||||
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.
|
||||
|
||||
Blades: Added new error message that displays when HTML5 video is not supported altogether. Make sure spinner gets hidden when error message is shown. BLD-638.
|
||||
|
||||
LMS: Switch default instructor dashboard to the new (formerly "beta")
|
||||
instructor dashboard. Puts the old (now "legacy") dash behind a feature flag.
|
||||
LMS-1296
|
||||
@@ -16,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.
|
||||
|
||||
@@ -204,8 +204,9 @@ def add_subsection(name='Subsection One'):
|
||||
|
||||
def set_date_and_time(date_css, desired_date, time_css, desired_time, key=None):
|
||||
set_element_value(date_css, desired_date, key)
|
||||
set_element_value(time_css, desired_time, key)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
set_element_value(time_css, desired_time, key)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
|
||||
@@ -38,14 +38,16 @@ Feature: CMS.Create Subsection
|
||||
Then I see the subsection release date is 12/25/2011 03:00
|
||||
And I see the subsection due date is 01/02/2012 04:00
|
||||
|
||||
# Disabling due to failure on master. JZ 05/14/2014 TODO: fix
|
||||
# Scenario: Set release and due dates of subsection on enter
|
||||
# Given I have opened a new subsection in Studio
|
||||
# And I set the subsection release date on enter to 04/04/2014 03:00
|
||||
# And I set the subsection due date on enter to 04/04/2014 04:00
|
||||
# And I reload the page
|
||||
# Then I see the subsection release date is 04/04/2014 03:00
|
||||
# And I see the subsection due date is 04/04/2014 04:00
|
||||
@skip_safari
|
||||
Scenario: Set release and due dates of subsection on enter
|
||||
Given I have opened a new subsection in Studio
|
||||
And I set the subsection release date on enter to 04/04/2014 03:00
|
||||
And I set the subsection due date on enter to 04/04/2014 04:00
|
||||
Then I see the subsection release date is 04/04/2014 03:00
|
||||
And I see the subsection due date is 04/04/2014 04:00
|
||||
And I reload the page
|
||||
Then I see the subsection release date is 04/04/2014 03:00
|
||||
And I see the subsection due date is 04/04/2014 04:00
|
||||
|
||||
Scenario: Delete a subsection
|
||||
Given I have opened a new course section in Studio
|
||||
@@ -56,16 +58,18 @@ Feature: CMS.Create Subsection
|
||||
And I confirm the prompt
|
||||
Then the subsection does not exist
|
||||
|
||||
# Disabling due to failure on master. JZ 05/14/2014 TODO: fix
|
||||
# Scenario: Sync to Section
|
||||
# Given I have opened a new course section in Studio
|
||||
# And I click the Edit link for the release date
|
||||
# And I set the section release date to 01/02/2103
|
||||
# And I have added a new subsection
|
||||
# And I click on the subsection
|
||||
# And I set the subsection release date to 01/20/2103
|
||||
# And I reload the page
|
||||
# And I click the link to sync release date to section
|
||||
# And I wait for "1" second
|
||||
# And I reload the page
|
||||
# Then I see the subsection release date is 01/02/2103
|
||||
@skip_safari
|
||||
Scenario: Sync to Section
|
||||
Given I have opened a new course section in Studio
|
||||
And I click the Edit link for the release date
|
||||
And I set the section release date to 01/02/2103
|
||||
And I have added a new subsection
|
||||
And I click on the subsection
|
||||
And I set the subsection release date to 06/20/2104
|
||||
Then I see the subsection release date is 06/20/2104
|
||||
And I reload the page
|
||||
Then I see the subsection release date is 06/20/2104
|
||||
And I click the link to sync release date to section
|
||||
And I wait for "1" second
|
||||
And I reload the page
|
||||
Then I see the subsection release date is 01/02/2103
|
||||
|
||||
@@ -64,19 +64,17 @@ def set_subsection_release_date_on_enter(_step, datestring, timestring): # pyli
|
||||
|
||||
|
||||
@step('I set the subsection due date to ([0-9/-]+)( [0-9:]+)?')
|
||||
def set_subsection_due_date(_step, datestring, timestring):
|
||||
def set_subsection_due_date(_step, datestring, timestring, key=None):
|
||||
if not world.css_visible('input#due_date'):
|
||||
world.css_click('.due-date-input .set-date')
|
||||
|
||||
set_subsection_date('input#due_date', datestring, 'input#due_time', timestring)
|
||||
assert world.css_visible('input#due_date')
|
||||
set_subsection_date('input#due_date', datestring, 'input#due_time', timestring, key)
|
||||
|
||||
|
||||
@step('I set the subsection due date on enter to ([0-9/-]+)( [0-9:]+)?')
|
||||
def set_subsection_due_date_on_enter(_step, datestring, timestring): # pylint: disable-msg=invalid-name
|
||||
if not world.css_visible('input#due_date'):
|
||||
world.css_click('.due-date-input .set-date')
|
||||
|
||||
set_subsection_date('input#due_date', datestring, 'input#due_time', timestring, 'ENTER')
|
||||
set_subsection_due_date(_step, datestring, timestring, 'ENTER')
|
||||
|
||||
|
||||
@step('I mark it as Homework$')
|
||||
|
||||
@@ -356,6 +356,17 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests the ajax callback to render an XModule
|
||||
"""
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
resp = self._test_preview(Location('i4x', 'edX', 'toy', 'vertical', 'vertical_test', None), 'container_preview')
|
||||
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. """
|
||||
>>>>>>> edx/master
|
||||
direct_store = modulestore('direct')
|
||||
_, course_items = import_from_xml(direct_store, 'common/test/data/', ['toy'])
|
||||
usage_key = course_items[0].id.make_usage_key('vertical', 'vertical_test')
|
||||
|
||||
@@ -157,70 +157,7 @@ def unit_handler(request, usage_key_string):
|
||||
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()
|
||||
|
||||
@@ -259,9 +196,15 @@ def unit_handler(request, usage_key_string):
|
||||
return render_to_response('unit.html', {
|
||||
'context_course': course,
|
||||
'unit': item,
|
||||
<<<<<<< HEAD
|
||||
'unit_locator': usage_key,
|
||||
'xblocks': xblocks,
|
||||
'component_templates': component_templates,
|
||||
=======
|
||||
'unit_locator': locator,
|
||||
'locators': locators,
|
||||
'component_templates': json.dumps(component_templates),
|
||||
>>>>>>> edx/master
|
||||
'draft_preview_link': preview_lms_link,
|
||||
'published_preview_link': lms_link,
|
||||
'subsection': containing_subsection,
|
||||
@@ -301,6 +244,7 @@ def container_handler(request, usage_key_string):
|
||||
except ItemNotFoundError:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
component_templates = _get_component_templates(course)
|
||||
ancestor_xblocks = []
|
||||
parent = get_parent_xblock(xblock)
|
||||
while parent and parent.category != 'sequential':
|
||||
@@ -317,11 +261,106 @@ def container_handler(request, usage_key_string):
|
||||
'xblock_locator': usage_key,
|
||||
'unit': None if not ancestor_xblocks else ancestor_xblocks[0],
|
||||
'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, usage_key):
|
||||
"""
|
||||
|
||||
@@ -9,7 +9,9 @@ from contentstore.utils import reverse_course_url, reverse_usage_url
|
||||
__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
|
||||
@@ -37,11 +39,20 @@ def render_from_lms(template_name, dictionary, context=None, namespace='main'):
|
||||
return render_to_string(template_name, dictionary, context, namespace="lms." + namespace)
|
||||
|
||||
|
||||
def _xmodule_recurse(item, action):
|
||||
for child in item.get_children():
|
||||
_xmodule_recurse(child, action)
|
||||
def _xmodule_recurse(item, action, ignore_exception=()):
|
||||
"""
|
||||
Recursively apply provided action on item and its children
|
||||
|
||||
action(item)
|
||||
ignore_exception (Exception Object): A optional argument; when passed ignores the corresponding
|
||||
exception raised during xmodule recursion,
|
||||
"""
|
||||
for child in item.get_children():
|
||||
_xmodule_recurse(child, action, ignore_exception)
|
||||
|
||||
try:
|
||||
return action(item)
|
||||
except ignore_exception:
|
||||
return
|
||||
|
||||
|
||||
def get_parent_xblock(xblock):
|
||||
@@ -58,40 +69,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):
|
||||
"""
|
||||
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':
|
||||
return reverse_course_url('course_handler', xblock.location.course_key)
|
||||
elif category == 'vertical' and parent_category == 'sequential':
|
||||
|
||||
@@ -20,8 +20,13 @@ from xblock.fields import Scope
|
||||
from xblock.fragment import Fragment
|
||||
|
||||
import xmodule
|
||||
<<<<<<< HEAD
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
|
||||
=======
|
||||
from xmodule.modulestore.django import modulestore, loc_mapper
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError, DuplicateItemError
|
||||
>>>>>>> edx/master
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from xmodule.video_module import manage_video_subtitles_save
|
||||
|
||||
@@ -31,7 +36,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
|
||||
@@ -177,6 +182,7 @@ def xblock_view_handler(request, usage_key_string, view_name):
|
||||
accept_header = request.META.get('HTTP_ACCEPT', 'application/json')
|
||||
|
||||
if 'application/json' in accept_header:
|
||||
<<<<<<< HEAD
|
||||
store = get_modulestore(usage_key)
|
||||
component = store.get_item(usage_key)
|
||||
is_read_only = _xblock_is_read_only(component)
|
||||
@@ -184,40 +190,64 @@ def xblock_view_handler(request, usage_key_string, view_name):
|
||||
# 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', usage_id_serializer=unicode))
|
||||
=======
|
||||
store = get_modulestore(old_location)
|
||||
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
|
||||
xblock.runtime.wrappers.append(partial(wrap_xblock, 'StudioRuntime'))
|
||||
>>>>>>> edx/master
|
||||
|
||||
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,
|
||||
<<<<<<< HEAD
|
||||
'xblock': component,
|
||||
'locator': usage_key,
|
||||
=======
|
||||
'xblock': xblock,
|
||||
'locator': locator,
|
||||
>>>>>>> edx/master
|
||||
})
|
||||
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
|
||||
@@ -226,10 +256,11 @@ def xblock_view_handler(request, usage_key_string, view_name):
|
||||
'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.
|
||||
@@ -237,7 +268,7 @@ def xblock_view_handler(request, usage_key_string, view_name):
|
||||
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
|
||||
@@ -255,7 +286,7 @@ def xblock_view_handler(request, usage_key_string, view_name):
|
||||
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.
|
||||
"""
|
||||
@@ -293,11 +324,19 @@ def _save_item(request, usage_key, data=None, children=None, metadata=None, null
|
||||
|
||||
if publish:
|
||||
if publish == 'make_private':
|
||||
_xmodule_recurse(existing_item, lambda i: modulestore().unpublish(i.location))
|
||||
_xmodule_recurse(
|
||||
existing_item,
|
||||
lambda i: modulestore().unpublish(i.location),
|
||||
ignore_exception=ItemNotFoundError
|
||||
)
|
||||
elif publish == 'create_draft':
|
||||
# This recursively clones the existing item location to a draft location (the draft is
|
||||
# implicit, because modulestore is a Draft modulestore)
|
||||
_xmodule_recurse(existing_item, lambda i: modulestore().convert_to_draft(i.location))
|
||||
_xmodule_recurse(
|
||||
existing_item,
|
||||
lambda i: modulestore().convert_to_draft(i.location),
|
||||
ignore_exception=DuplicateItemError
|
||||
)
|
||||
|
||||
if data:
|
||||
# TODO Allow any scope.content fields not just "data" (exactly like the get below this)
|
||||
@@ -393,7 +432,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)
|
||||
|
||||
@@ -27,7 +27,12 @@ from util.sandboxing import can_execute_unsafe_code
|
||||
|
||||
import static_replace
|
||||
from .session_kv_store import SessionKeyValueStore
|
||||
<<<<<<< HEAD
|
||||
from .helpers import render_from_lms
|
||||
=======
|
||||
from .helpers import render_from_lms, xblock_has_own_studio_page
|
||||
from ..utils import get_course_for_item
|
||||
>>>>>>> edx/master
|
||||
|
||||
from contentstore.views.access import get_user_role
|
||||
|
||||
@@ -156,6 +161,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):
|
||||
"""
|
||||
@@ -163,15 +175,23 @@ 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':
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
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)
|
||||
>>>>>>> edx/master
|
||||
template_context = {
|
||||
'xblock_context': context,
|
||||
'xblock': xblock,
|
||||
'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)
|
||||
|
||||
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)
|
||||
@@ -612,6 +612,80 @@ class TestEditItem(ItemTest):
|
||||
draft = self.get_item_from_modulestore(self.problem_usage_key, True)
|
||||
self.assertEqual(draft.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC))
|
||||
|
||||
def test_create_draft_with_multiple_requests(self):
|
||||
"""
|
||||
Create a draft request returns already created version if it exists.
|
||||
"""
|
||||
# Make problem public.
|
||||
self.client.ajax_post(
|
||||
self.problem_update_url,
|
||||
data={'publish': 'make_public'}
|
||||
)
|
||||
self.assertIsNotNone(self.get_item_from_modulestore(self.problem_locator, False))
|
||||
# Now make it draft, which means both versions will exist.
|
||||
self.client.ajax_post(
|
||||
self.problem_update_url,
|
||||
data={
|
||||
'publish': 'create_draft'
|
||||
}
|
||||
)
|
||||
self.assertIsNotNone(self.get_item_from_modulestore(self.problem_locator, False))
|
||||
draft_1 = self.get_item_from_modulestore(self.problem_locator, True)
|
||||
self.assertIsNotNone(draft_1)
|
||||
|
||||
# Now check that when a user sends request to create a draft when there is already a draft version then
|
||||
# user gets that already created draft instead of getting 'DuplicateItemError' exception.
|
||||
self.client.ajax_post(
|
||||
self.problem_update_url,
|
||||
data={
|
||||
'publish': 'create_draft'
|
||||
}
|
||||
)
|
||||
draft_2 = self.get_item_from_modulestore(self.problem_locator, True)
|
||||
self.assertIsNotNone(draft_2)
|
||||
self.assertEqual(draft_1, draft_2)
|
||||
|
||||
|
||||
def test_make_private_with_multiple_requests(self):
|
||||
"""
|
||||
Make private requests gets proper response even if xmodule is already made private.
|
||||
"""
|
||||
# Make problem public.
|
||||
self.client.ajax_post(
|
||||
self.problem_update_url,
|
||||
data={'publish': 'make_public'}
|
||||
)
|
||||
self.assertIsNotNone(self.get_item_from_modulestore(self.problem_locator, False))
|
||||
|
||||
# Now make it private, and check that its published version not exists
|
||||
resp = self.client.ajax_post(
|
||||
self.problem_update_url,
|
||||
data={
|
||||
'publish': 'make_private'
|
||||
}
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
with self.assertRaises(ItemNotFoundError):
|
||||
self.get_item_from_modulestore(self.problem_locator, False)
|
||||
draft_1 = self.get_item_from_modulestore(self.problem_locator, True)
|
||||
self.assertIsNotNone(draft_1)
|
||||
|
||||
# Now check that when a user sends request to make it private when it already is private then
|
||||
# user gets that private version instead of getting 'ItemNotFoundError' exception.
|
||||
self.client.ajax_post(
|
||||
self.problem_update_url,
|
||||
data={
|
||||
'publish': 'make_private'
|
||||
}
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
with self.assertRaises(ItemNotFoundError):
|
||||
self.get_item_from_modulestore(self.problem_locator, False)
|
||||
draft_2 = self.get_item_from_modulestore(self.problem_locator, True)
|
||||
self.assertIsNotNone(draft_2)
|
||||
self.assertEqual(draft_1, draft_2)
|
||||
|
||||
|
||||
def test_published_and_draft_contents_with_update(self):
|
||||
""" Create a draft and publish it then modify the draft and check that published content is not modified """
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -85,9 +85,12 @@ FEATURES = {
|
||||
# Hide any Personally Identifiable Information from application logs
|
||||
'SQUELCH_PII_IN_LOGS': False,
|
||||
|
||||
# Toggles embargo functionality
|
||||
# Toggles the embargo functionality, which enable embargoing for particular courses
|
||||
'EMBARGO': False,
|
||||
|
||||
# Toggles the embargo site functionality, which enable embargoing for the whole site
|
||||
'SITE_EMBARGOED': False,
|
||||
|
||||
# Turn on/off Microsites feature
|
||||
'USE_MICROSITES': False,
|
||||
|
||||
@@ -99,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
|
||||
|
||||
@@ -302,6 +299,9 @@ LOCALE_PATHS = (REPO_ROOT + '/conf/locale',) # edx-platform/conf/locale/
|
||||
# Messages
|
||||
MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage'
|
||||
|
||||
##### EMBARGO #####
|
||||
EMBARGO_SITE_REDIRECT_URL = None
|
||||
|
||||
############################### Pipeline #######################################
|
||||
|
||||
STATICFILES_STORAGE = 'pipeline.storage.PipelineCachedStorage'
|
||||
@@ -318,7 +318,7 @@ PIPELINE_CSS = {
|
||||
'css/vendor/ui-lightness/jquery-ui-1.8.22.custom.css',
|
||||
'css/vendor/jquery.qtip.min.css',
|
||||
'js/vendor/markitup/skins/simple/style.css',
|
||||
'js/vendor/markitup/sets/wiki/style.css'
|
||||
'js/vendor/markitup/sets/wiki/style.css',
|
||||
],
|
||||
'output_filename': 'css/cms-style-vendor.css',
|
||||
},
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
<<<<<<< HEAD
|
||||
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) {
|
||||
@@ -8,233 +9,183 @@ define(["coffee/src/views/unit", "js/models/module_info", "js/spec_helpers/creat
|
||||
// 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
|
||||
);
|
||||
>>>>>>> edx/master
|
||||
};
|
||||
|
||||
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,12 +7,17 @@
|
||||
* 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) {
|
||||
<<<<<<< HEAD
|
||||
if (locator === undefined) {
|
||||
return urlRoot + "/";
|
||||
=======
|
||||
if (_.isUndefined(locator)) {
|
||||
return urlRoot;
|
||||
>>>>>>> edx/master
|
||||
}
|
||||
else {
|
||||
return urlRoot + "/" + locator;
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -36,7 +36,10 @@ define(["domReady", "jquery", "jquery.ui", "underscore", "gettext", "js/views/fe
|
||||
};
|
||||
|
||||
|
||||
var closeModalNew = function () {
|
||||
var closeModalNew = function (e) {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
};
|
||||
$('body').removeClass('modal-window-is-shown');
|
||||
$('.edit-section-publish-settings').removeClass('is-shown');
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
@@ -517,7 +517,7 @@
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
overflow: scroll;
|
||||
background: $black-t2;
|
||||
|
||||
@@ -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,11 @@ main_xblock_info = {
|
||||
<section class="content-area">
|
||||
|
||||
<article class="content-primary window">
|
||||
<<<<<<< HEAD
|
||||
<section class="wrapper-xblock level-page is-hidden" data-locator="${xblock_locator}" data-course-key="${xblock_locator.course_key}">
|
||||
=======
|
||||
<section class="wrapper-xblock level-page is-hidden studio-xblock-wrapper" data-locator="${xblock_locator}">
|
||||
>>>>>>> edx/master
|
||||
</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>
|
||||
|
||||
@@ -83,7 +83,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>
|
||||
@@ -193,7 +193,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>
|
||||
@@ -228,7 +228,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,40 +1,63 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%! from django.conf import settings %>
|
||||
|
||||
<<<<<<< HEAD
|
||||
% 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="${xblock.location}" data-display-name="${xblock.display_name_with_default | h}" data-category="${xblock.category | h}" data-course-key="${xblock.location.course_key}">
|
||||
=======
|
||||
% 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}">
|
||||
>>>>>>> edx/master
|
||||
% 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 +66,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
|
||||
|
||||
@@ -20,13 +20,12 @@ from django.utils.translation import ugettext as _
|
||||
|
||||
<%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'),
|
||||
@@ -34,7 +33,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(){
|
||||
@@ -63,87 +63,8 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
|
||||
% for xblock in xblocks:
|
||||
<li class="component" data-locator="${xblock.location}" data-course-key="${xblock.location.course_key}"/>
|
||||
% 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">
|
||||
|
||||
|
||||
@@ -34,7 +34,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.location}"><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>
|
||||
@@ -53,6 +53,3 @@ This def will enumerate through a passed in subsection and list all of the units
|
||||
</li>
|
||||
</ol>
|
||||
</%def>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from django import forms
|
||||
from embargo.models import EmbargoedCourse, EmbargoedState, IPFilter
|
||||
from embargo.fixtures.country_codes import COUNTRY_CODES
|
||||
|
||||
import socket
|
||||
import ipaddr
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from opaque_keys import InvalidKeyError
|
||||
@@ -88,21 +88,12 @@ class IPFilterForm(forms.ModelForm): # pylint: disable=incomplete-protocol
|
||||
class Meta: # pylint: disable=missing-docstring
|
||||
model = IPFilter
|
||||
|
||||
def _is_valid_ipv4(self, address):
|
||||
"""Whether or not address is a valid ipv4 address"""
|
||||
def _is_valid_ip(self, address):
|
||||
"""Whether or not address is a valid ipv4 address or ipv6 address"""
|
||||
try:
|
||||
# Is this an ipv4 address?
|
||||
socket.inet_pton(socket.AF_INET, address)
|
||||
except socket.error:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _is_valid_ipv6(self, address):
|
||||
"""Whether or not address is a valid ipv6 address"""
|
||||
try:
|
||||
# Is this an ipv6 address?
|
||||
socket.inet_pton(socket.AF_INET6, address)
|
||||
except socket.error:
|
||||
# Is this an valid ip address?
|
||||
ipaddr.IPNetwork(address)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -117,7 +108,7 @@ class IPFilterForm(forms.ModelForm): # pylint: disable=incomplete-protocol
|
||||
error_addresses = []
|
||||
for addr in addresses.split(','):
|
||||
address = addr.strip()
|
||||
if not (self._is_valid_ipv4(address) or self._is_valid_ipv6(address)):
|
||||
if not self._is_valid_ip(address):
|
||||
error_addresses.append(address)
|
||||
if error_addresses:
|
||||
msg = 'Invalid IP Address(es): {0}'.format(error_addresses)
|
||||
|
||||
@@ -1,11 +1,34 @@
|
||||
"""
|
||||
Middleware for embargoing courses.
|
||||
"""Middleware for embargoing site and courses.
|
||||
|
||||
IMPORTANT NOTE: This code WILL NOT WORK if you have a misconfigured proxy
|
||||
server. If you are configuring embargo functionality, or if you are
|
||||
experiencing mysterious problems with embargoing, please check that your
|
||||
reverse proxy is setting any of the well known client IP address headers (ex.,
|
||||
HTTP_X_FORWARDED_FOR).
|
||||
|
||||
This middleware allows you to:
|
||||
|
||||
* Embargoing courses (access restriction by courses)
|
||||
* Embargoing site (access restriction of the main site)
|
||||
|
||||
Embargo can restrict by states and whitelist/blacklist (IP Addresses
|
||||
(ie. 10.0.0.0) or Networks (ie. 10.0.0.0/24)).
|
||||
|
||||
Usage:
|
||||
|
||||
# Enable the middleware in your settings
|
||||
|
||||
# To enable Embargo for particular courses, set:
|
||||
FEATURES['EMBARGO'] = True # blocked ip will be redirected to /embargo
|
||||
|
||||
# To enable the Embargo feature for the whole site, set:
|
||||
FEATURES['SITE_EMBARGOED'] = True
|
||||
|
||||
# With SITE_EMBARGOED, you can define an external url to redirect with:
|
||||
EMBARGO_SITE_REDIRECT_URL = 'https://www.edx.org/'
|
||||
|
||||
# if EMBARGO_SITE_REDIRECT_URL is missing, a HttpResponseForbidden is returned.
|
||||
|
||||
"""
|
||||
import logging
|
||||
import pygeoip
|
||||
@@ -13,6 +36,7 @@ import pygeoip
|
||||
from django.core.exceptions import MiddlewareNotUsed
|
||||
from django.conf import settings
|
||||
from django.shortcuts import redirect
|
||||
from django.http import HttpResponseRedirect, HttpResponseForbidden
|
||||
from ipware.ip import get_ip
|
||||
from util.request import course_id_from_url
|
||||
|
||||
@@ -23,14 +47,16 @@ log = logging.getLogger(__name__)
|
||||
|
||||
class EmbargoMiddleware(object):
|
||||
"""
|
||||
Middleware for embargoing courses
|
||||
Middleware for embargoing site and courses
|
||||
|
||||
This is configured by creating ``EmbargoedCourse``, ``EmbargoedState``, and
|
||||
optionally ``IPFilter`` rows in the database, using the django admin site.
|
||||
"""
|
||||
def __init__(self):
|
||||
self.site_enabled = settings.FEATURES.get('SITE_EMBARGOED', False)
|
||||
# If embargoing is turned off, make this middleware do nothing
|
||||
if not settings.FEATURES.get('EMBARGO', False):
|
||||
if not settings.FEATURES.get('EMBARGO', False) and \
|
||||
not self.site_enabled:
|
||||
raise MiddlewareNotUsed()
|
||||
|
||||
def process_request(self, request):
|
||||
@@ -39,23 +65,41 @@ class EmbargoMiddleware(object):
|
||||
"""
|
||||
url = request.path
|
||||
course_id = course_id_from_url(url)
|
||||
course_is_embargoed = EmbargoedCourse.is_embargoed(course_id)
|
||||
|
||||
# If they're trying to access a course that cares about embargoes
|
||||
if EmbargoedCourse.is_embargoed(course_id):
|
||||
if self.site_enabled or course_is_embargoed:
|
||||
response = redirect('embargo')
|
||||
# Set the proper response if site is enabled
|
||||
if self.site_enabled:
|
||||
redirect_url = getattr(settings, 'EMBARGO_SITE_REDIRECT_URL', None)
|
||||
response = HttpResponseRedirect(redirect_url) if redirect_url \
|
||||
else HttpResponseForbidden('Access Denied')
|
||||
|
||||
# If we're having performance issues, add caching here
|
||||
ip_addr = get_ip(request)
|
||||
|
||||
# if blacklisted, immediately fail
|
||||
if ip_addr in IPFilter.current().blacklist_ips:
|
||||
log.info("Embargo: Restricting IP address %s to course %s because IP is blacklisted.", ip_addr, course_id)
|
||||
return redirect('embargo')
|
||||
if course_is_embargoed:
|
||||
msg = "Embargo: Restricting IP address %s to course %s because IP is blacklisted." % \
|
||||
(ip_addr, course_id)
|
||||
else:
|
||||
msg = "Embargo: Restricting IP address %s because IP is blacklisted." % ip_addr
|
||||
|
||||
log.info(msg)
|
||||
return response
|
||||
|
||||
country_code_from_ip = pygeoip.GeoIP(settings.GEOIP_PATH).country_code_by_addr(ip_addr)
|
||||
is_embargoed = country_code_from_ip in EmbargoedState.current().embargoed_countries_list
|
||||
# Fail if country is embargoed and the ip address isn't explicitly whitelisted
|
||||
if is_embargoed and ip_addr not in IPFilter.current().whitelist_ips:
|
||||
log.info(
|
||||
"Embargo: Restricting IP address %s to course %s because IP is from country %s.",
|
||||
ip_addr, course_id, country_code_from_ip
|
||||
)
|
||||
return redirect('embargo')
|
||||
if course_is_embargoed:
|
||||
msg = "Embargo: Restricting IP address %s to course %s because IP is from country %s." % \
|
||||
(ip_addr, course_id, country_code_from_ip)
|
||||
else:
|
||||
msg = "Embargo: Restricting IP address %s because IP is from country %s." % \
|
||||
(ip_addr, country_code_from_ip)
|
||||
|
||||
log.info(msg)
|
||||
return response
|
||||
|
||||
@@ -10,6 +10,9 @@ file and check it in at the same time as your model changes. To do that,
|
||||
2. ./manage.py lms schemamigration embargo --auto description_of_your_change
|
||||
3. Add the migration file created in edx-platform/common/djangoapps/embargo/migrations/
|
||||
"""
|
||||
|
||||
import ipaddr
|
||||
|
||||
from django.db import models
|
||||
|
||||
from config_models.models import ConfigurationModel
|
||||
@@ -83,6 +86,30 @@ class IPFilter(ConfigurationModel):
|
||||
help_text="A comma-separated list of IP addresses that should fall under embargo restrictions."
|
||||
)
|
||||
|
||||
class IPFilterList(object):
|
||||
"""
|
||||
Represent a list of IP addresses with support of networks.
|
||||
"""
|
||||
|
||||
def __init__(self, ips):
|
||||
self.networks = [ipaddr.IPNetwork(ip) for ip in ips]
|
||||
|
||||
def __iter__(self):
|
||||
for network in self.networks:
|
||||
yield network
|
||||
|
||||
def __contains__(self, ip):
|
||||
try:
|
||||
ip = ipaddr.IPAddress(ip)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
for network in self.networks:
|
||||
if network.Contains(ip):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def whitelist_ips(self):
|
||||
"""
|
||||
@@ -90,7 +117,7 @@ class IPFilter(ConfigurationModel):
|
||||
"""
|
||||
if self.whitelist == '':
|
||||
return []
|
||||
return [addr.strip() for addr in self.whitelist.split(',')] # pylint: disable=no-member
|
||||
return self.IPFilterList([addr.strip() for addr in self.whitelist.split(',')]) # pylint: disable=no-member
|
||||
|
||||
@property
|
||||
def blacklist_ips(self):
|
||||
@@ -99,4 +126,4 @@ class IPFilter(ConfigurationModel):
|
||||
"""
|
||||
if self.blacklist == '':
|
||||
return []
|
||||
return [addr.strip() for addr in self.blacklist.split(',')] # pylint: disable=no-member
|
||||
return self.IPFilterList([addr.strip() for addr in self.blacklist.split(',')]) # pylint: disable=no-member
|
||||
|
||||
@@ -156,8 +156,8 @@ class IPFilterFormTest(TestCase):
|
||||
# should be able to do both ipv4 and ipv6
|
||||
# spacing should not matter
|
||||
form_data = {
|
||||
'whitelist': '127.0.0.1, 2003:dead:beef:4dad:23:46:bb:101',
|
||||
'blacklist': ' 18.244.1.5 , 2002:c0a8:101::42, 18.36.22.1'
|
||||
'whitelist': '127.0.0.1, 2003:dead:beef:4dad:23:46:bb:101, 1.1.0.1/32, 1.0.0.0/24',
|
||||
'blacklist': ' 18.244.1.5 , 2002:c0a8:101::42, 18.36.22.1, 1.0.0.0/16'
|
||||
}
|
||||
form = IPFilterForm(data=form_data)
|
||||
self.assertTrue(form.is_valid())
|
||||
@@ -169,6 +169,20 @@ class IPFilterFormTest(TestCase):
|
||||
for addr in '18.244.1.5, 2002:c0a8:101::42, 18.36.22.1'.split(','):
|
||||
self.assertIn(addr.strip(), blacklist)
|
||||
|
||||
# Network tests
|
||||
# ips not in whitelist network
|
||||
for addr in ['1.1.0.2', '1.0.1.0']:
|
||||
self.assertNotIn(addr.strip(), whitelist)
|
||||
# ips in whitelist network
|
||||
for addr in ['1.1.0.1', '1.0.0.100']:
|
||||
self.assertIn(addr.strip(), whitelist)
|
||||
# ips not in blacklist network
|
||||
for addr in ['2.0.0.0', '1.1.0.0']:
|
||||
self.assertNotIn(addr.strip(), blacklist)
|
||||
# ips in blacklist network
|
||||
for addr in ['1.0.100.0', '1.0.0.10']:
|
||||
self.assertIn(addr.strip(), blacklist)
|
||||
|
||||
# Test clearing by adding an empty list is OK too
|
||||
form_data = {
|
||||
'whitelist': '',
|
||||
@@ -183,15 +197,15 @@ class IPFilterFormTest(TestCase):
|
||||
def test_add_invalid_ips(self):
|
||||
# test adding invalid ip addresses
|
||||
form_data = {
|
||||
'whitelist': '.0.0.1, :dead:beef:::',
|
||||
'blacklist': ' 18.244.* , 999999:c0a8:101::42'
|
||||
'whitelist': '.0.0.1, :dead:beef:::, 1.0.0.0/55',
|
||||
'blacklist': ' 18.244.* , 999999:c0a8:101::42, 1.0.0.0/'
|
||||
}
|
||||
form = IPFilterForm(data=form_data)
|
||||
self.assertFalse(form.is_valid())
|
||||
|
||||
wmsg = "Invalid IP Address(es): [u'.0.0.1', u':dead:beef:::'] Please fix the error(s) and try again."
|
||||
wmsg = "Invalid IP Address(es): [u'.0.0.1', u':dead:beef:::', u'1.0.0.0/55'] Please fix the error(s) and try again."
|
||||
self.assertEquals(wmsg, form._errors['whitelist'][0]) # pylint: disable=protected-access
|
||||
bmsg = "Invalid IP Address(es): [u'18.244.*', u'999999:c0a8:101::42'] Please fix the error(s) and try again."
|
||||
bmsg = "Invalid IP Address(es): [u'18.244.*', u'999999:c0a8:101::42', u'1.0.0.0/'] Please fix the error(s) and try again."
|
||||
self.assertEquals(bmsg, form._errors['blacklist'][0]) # pylint: disable=protected-access
|
||||
|
||||
with self.assertRaisesRegexp(ValueError, "The IPFilter could not be created because the data didn't validate."):
|
||||
|
||||
@@ -129,6 +129,62 @@ class EmbargoMiddlewareTests(TestCase):
|
||||
response = self.client.get(self.regular_page, HTTP_X_FORWARDED_FOR='5.0.0.0', REMOTE_ADDR='5.0.0.0')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
def test_ip_network_exceptions(self):
|
||||
# Explicitly whitelist/blacklist some IP networks
|
||||
IPFilter(
|
||||
whitelist='1.0.0.1/24',
|
||||
blacklist='5.0.0.0/16,1.1.0.0/24',
|
||||
changed_by=self.user,
|
||||
enabled=True
|
||||
).save()
|
||||
|
||||
# Accessing an embargoed page from a blocked IP that's been whitelisted with a network
|
||||
# should succeed
|
||||
response = self.client.get(self.embargoed_page, HTTP_X_FORWARDED_FOR='1.0.0.0', REMOTE_ADDR='1.0.0.0')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Accessing a regular course from a blocked IP that's been whitelisted with a network
|
||||
# should succeed
|
||||
response = self.client.get(self.regular_page, HTTP_X_FORWARDED_FOR='1.0.0.0', REMOTE_ADDR='1.0.0.0')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Accessing an embargoed course from non-embargoed IP that's been blacklisted with a network
|
||||
# should cause a redirect
|
||||
response = self.client.get(self.embargoed_page, HTTP_X_FORWARDED_FOR='5.0.0.100', REMOTE_ADDR='5.0.0.100')
|
||||
self.assertEqual(response.status_code, 302)
|
||||
# Following the redirect should give us the embargo page
|
||||
response = self.client.get(
|
||||
self.embargoed_page,
|
||||
HTTP_X_FORWARDED_FOR='5.0.0.100',
|
||||
REMOTE_ADDR='5.0.0.100',
|
||||
follow=True
|
||||
)
|
||||
self.assertIn(self.embargo_text, response.content)
|
||||
|
||||
# Accessing an embargoed course from non-embargoed IP that's been blaclisted with a network
|
||||
# should cause a redirect
|
||||
response = self.client.get(self.embargoed_page, HTTP_X_FORWARDED_FOR='1.1.0.1', REMOTE_ADDR='1.1.0.1')
|
||||
self.assertEqual(response.status_code, 302)
|
||||
# Following the redirect should give us the embargo page
|
||||
response = self.client.get(
|
||||
self.embargoed_page,
|
||||
HTTP_X_FORWARDED_FOR='1.1.0.0',
|
||||
REMOTE_ADDR='1.1.0.0',
|
||||
follow=True
|
||||
)
|
||||
self.assertIn(self.embargo_text, response.content)
|
||||
|
||||
# Accessing an embargoed from a blocked IP that's not blacklisted by the network rule.
|
||||
# should succeed
|
||||
response = self.client.get(self.embargoed_page, HTTP_X_FORWARDED_FOR='1.1.1.0', REMOTE_ADDR='1.1.1.0')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Accessing a regular course from a non-embargoed IP that's been blacklisted
|
||||
# should succeed
|
||||
response = self.client.get(self.regular_page, HTTP_X_FORWARDED_FOR='5.0.0.0', REMOTE_ADDR='5.0.0.0')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
@mock.patch.dict(settings.FEATURES, {'EMBARGO': False})
|
||||
def test_countries_embargo_off(self):
|
||||
@@ -157,3 +213,25 @@ class EmbargoMiddlewareTests(TestCase):
|
||||
# Accessing a regular course from a non-embargoed IP that's been blacklisted should succeed
|
||||
response = self.client.get(self.regular_page, HTTP_X_FORWARDED_FOR='5.0.0.0', REMOTE_ADDR='5.0.0.0')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
@mock.patch.dict(settings.FEATURES, {'EMBARGO': False, 'SITE_EMBARGOED': True})
|
||||
def test_embargo_off_embargo_site_on(self):
|
||||
# When the middleware is turned on with SITE, main site access should be restricted
|
||||
# Accessing a regular page from a blocked IP is denied.
|
||||
response = self.client.get(self.regular_page, HTTP_X_FORWARDED_FOR='1.0.0.0', REMOTE_ADDR='1.0.0.0')
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
# Accessing a regular page from a non blocked IP should succeed
|
||||
response = self.client.get(self.regular_page, HTTP_X_FORWARDED_FOR='5.0.0.0', REMOTE_ADDR='5.0.0.0')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
@mock.patch.dict(settings.FEATURES, {'EMBARGO': False, 'SITE_EMBARGOED': True})
|
||||
@override_settings(EMBARGO_SITE_REDIRECT_URL='https://www.edx.org/')
|
||||
def test_embargo_off_embargo_site_on_with_redirect_url(self):
|
||||
# When the middleware is turned on with SITE_EMBARGOED, main site access
|
||||
# should be restricted. Accessing a regular page from a blocked IP is
|
||||
# denied, and redirected to EMBARGO_SITE_REDIRECT_URL rather than returning a 403.
|
||||
response = self.client.get(self.regular_page, HTTP_X_FORWARDED_FOR='1.0.0.0', REMOTE_ADDR='1.0.0.0')
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
@@ -79,3 +79,19 @@ class EmbargoModelsTest(TestCase):
|
||||
self.assertTrue(whitelist in cwhitelist)
|
||||
cblacklist = IPFilter.current().blacklist_ips
|
||||
self.assertTrue(blacklist in cblacklist)
|
||||
|
||||
def test_ip_network_blocking(self):
|
||||
whitelist = '1.0.0.0/24'
|
||||
blacklist = '1.1.0.0/16'
|
||||
|
||||
IPFilter(whitelist=whitelist, blacklist=blacklist).save()
|
||||
|
||||
cwhitelist = IPFilter.current().whitelist_ips
|
||||
self.assertTrue('1.0.0.100' in cwhitelist)
|
||||
self.assertTrue('1.0.0.10' in cwhitelist)
|
||||
self.assertFalse('1.0.1.0' in cwhitelist)
|
||||
cblacklist = IPFilter.current().blacklist_ips
|
||||
self.assertTrue('1.1.0.0' in cblacklist)
|
||||
self.assertTrue('1.1.0.1' in cblacklist)
|
||||
self.assertTrue('1.1.1.0' in cblacklist)
|
||||
self.assertFalse('1.2.0.0' in cblacklist)
|
||||
|
||||
@@ -208,6 +208,58 @@ class ShibSPTest(ModuleStoreTestCase):
|
||||
# no audit logging calls
|
||||
self.assertEquals(len(audit_log_calls), 0)
|
||||
|
||||
def _base_test_extauth_auto_activate_user_with_flag(self, log_user_string="inactive@stanford.edu"):
|
||||
"""
|
||||
Tests that FEATURES['BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'] means extauth automatically
|
||||
linked users, activates them, and logs them in
|
||||
"""
|
||||
inactive_user = UserFactory.create(email='inactive@stanford.edu')
|
||||
inactive_user.is_active = False
|
||||
inactive_user.save()
|
||||
request = self.request_factory.get('/shib-login')
|
||||
request.session = import_module(settings.SESSION_ENGINE).SessionStore() # empty session
|
||||
request.META.update({
|
||||
'Shib-Identity-Provider': 'https://idp.stanford.edu/',
|
||||
'REMOTE_USER': 'inactive@stanford.edu',
|
||||
'mail': 'inactive@stanford.edu'
|
||||
})
|
||||
|
||||
request.user = AnonymousUser()
|
||||
with patch('external_auth.views.AUDIT_LOG') as mock_audit_log:
|
||||
response = shib_login(request)
|
||||
audit_log_calls = mock_audit_log.method_calls
|
||||
# reload user from db, since the view function works via db side-effects
|
||||
inactive_user = User.objects.get(id=inactive_user.id)
|
||||
self.assertIsNotNone(ExternalAuthMap.objects.get(user=inactive_user))
|
||||
self.assertTrue(inactive_user.is_active)
|
||||
self.assertIsInstance(response, HttpResponseRedirect)
|
||||
self.assertEqual(request.user, inactive_user)
|
||||
self.assertEqual(response['Location'], '/')
|
||||
# verify logging:
|
||||
self.assertEquals(len(audit_log_calls), 3)
|
||||
self._assert_shib_login_is_logged(audit_log_calls[0], log_user_string)
|
||||
method_name, args, _kwargs = audit_log_calls[2]
|
||||
self.assertEquals(method_name, 'info')
|
||||
self.assertEquals(len(args), 1)
|
||||
self.assertIn(u'Login success', args[0])
|
||||
self.assertIn(log_user_string, args[0])
|
||||
|
||||
@unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set")
|
||||
@patch.dict(settings.FEATURES, {'BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH': True, 'SQUELCH_PII_IN_LOGS': False})
|
||||
def test_extauth_auto_activate_user_with_flag_no_squelch(self):
|
||||
"""
|
||||
Wrapper to run base_test_extauth_auto_activate_user_with_flag with {'SQUELCH_PII_IN_LOGS': False}
|
||||
"""
|
||||
self._base_test_extauth_auto_activate_user_with_flag(log_user_string="inactive@stanford.edu")
|
||||
|
||||
@unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set")
|
||||
@patch.dict(settings.FEATURES, {'BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH': True, 'SQUELCH_PII_IN_LOGS': True})
|
||||
def test_extauth_auto_activate_user_with_flag_squelch(self):
|
||||
"""
|
||||
Wrapper to run base_test_extauth_auto_activate_user_with_flag with {'SQUELCH_PII_IN_LOGS': True}
|
||||
"""
|
||||
self._base_test_extauth_auto_activate_user_with_flag(log_user_string="user.id: 1")
|
||||
|
||||
@unittest.skipUnless(settings.FEATURES.get('AUTH_USE_SHIB'), "AUTH_USE_SHIB not set")
|
||||
def test_registration_form(self):
|
||||
"""
|
||||
|
||||
@@ -216,13 +216,23 @@ def _external_login_or_signup(request,
|
||||
return _signup(request, eamap, retfun)
|
||||
|
||||
if not user.is_active:
|
||||
if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
|
||||
AUDIT_LOG.warning('User {0} is not active after external login'.format(user.id))
|
||||
if settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'):
|
||||
# if BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH, we trust external auth and activate any users
|
||||
# that aren't already active
|
||||
user.is_active = True
|
||||
user.save()
|
||||
if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
|
||||
AUDIT_LOG.info('Activating user {0} due to external auth'.format(user.id))
|
||||
else:
|
||||
AUDIT_LOG.info('Activating user "{0}" due to external auth'.format(uname))
|
||||
else:
|
||||
AUDIT_LOG.warning('User "{0}" is not active after external login'.format(uname))
|
||||
# TODO: improve error page
|
||||
msg = 'Account not yet activated: please look for link in your email'
|
||||
return default_render_failure(request, msg)
|
||||
if settings.FEATURES['SQUELCH_PII_IN_LOGS']:
|
||||
AUDIT_LOG.warning('User {0} is not active after external login'.format(user.id))
|
||||
else:
|
||||
AUDIT_LOG.warning('User "{0}" is not active after external login'.format(uname))
|
||||
# TODO: improve error page
|
||||
msg = 'Account not yet activated: please look for link in your email'
|
||||
return default_render_failure(request, msg)
|
||||
|
||||
login(request, user)
|
||||
request.session.set_expiry(0)
|
||||
|
||||
@@ -6,7 +6,7 @@ from pipeline.utils import guess_type
|
||||
from static_replace import try_staticfiles_lookup
|
||||
|
||||
|
||||
def compressed_css(package_name):
|
||||
def compressed_css(package_name, raw=False):
|
||||
package = settings.PIPELINE_CSS.get(package_name, {})
|
||||
if package:
|
||||
package = {package_name: package}
|
||||
@@ -15,17 +15,19 @@ def compressed_css(package_name):
|
||||
package = packager.package_for('css', package_name)
|
||||
|
||||
if settings.PIPELINE:
|
||||
return render_css(package, package.output_filename)
|
||||
return render_css(package, package.output_filename, raw=raw)
|
||||
else:
|
||||
paths = packager.compile(package.paths)
|
||||
return render_individual_css(package, paths)
|
||||
return render_individual_css(package, paths, raw=raw)
|
||||
|
||||
|
||||
def render_css(package, path):
|
||||
def render_css(package, path, raw=False):
|
||||
template_name = package.template_name or "mako/css.html"
|
||||
context = package.extra_context
|
||||
|
||||
url = try_staticfiles_lookup(path)
|
||||
if raw:
|
||||
url += "?raw"
|
||||
context.update({
|
||||
'type': guess_type(path, 'text/css'),
|
||||
'url': url,
|
||||
@@ -33,8 +35,8 @@ def render_css(package, path):
|
||||
return render_to_string(template_name, context)
|
||||
|
||||
|
||||
def render_individual_css(package, paths):
|
||||
tags = [render_css(package, path) for path in paths]
|
||||
def render_individual_css(package, paths, raw=False):
|
||||
tags = [render_css(package, path, raw) for path in paths]
|
||||
return '\n'.join(tags)
|
||||
|
||||
|
||||
|
||||
@@ -3,19 +3,19 @@ from staticfiles.storage import staticfiles_storage
|
||||
from pipeline_mako import compressed_css, compressed_js
|
||||
%>
|
||||
|
||||
<%def name='url(file)'><%
|
||||
<%def name='url(file, raw=False)'><%
|
||||
try:
|
||||
url = staticfiles_storage.url(file)
|
||||
except:
|
||||
url = file
|
||||
%>${url}</%def>
|
||||
%>${url}${"?raw" if raw else ""}</%def>
|
||||
|
||||
<%def name='css(group)'>
|
||||
<%def name='css(group, raw=False)'>
|
||||
% if settings.FEATURES['USE_DJANGO_PIPELINE']:
|
||||
${compressed_css(group)}
|
||||
${compressed_css(group, raw=raw)}
|
||||
% else:
|
||||
% for filename in settings.PIPELINE_CSS[group]['source_filenames']:
|
||||
<link rel="stylesheet" href="${staticfiles_storage.url(filename.replace('.scss', '.css'))}" type="text/css" media="all" / >
|
||||
<link rel="stylesheet" href="${staticfiles_storage.url(filename.replace('.scss', '.css'))}${"?raw" if raw else ""}" type="text/css" media="all" / >
|
||||
% endfor
|
||||
%endif
|
||||
</%def>
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
'''
|
||||
Firebase - library to generate a token
|
||||
License: https://github.com/firebase/firebase-token-generator-python/blob/master/LICENSE
|
||||
Tweaked and Edited by @danielcebrianr and @lduarte1991
|
||||
|
||||
This library will take either objects or strings and use python's built-in encoding
|
||||
system as specified by RFC 3548. Thanks to the firebase team for their open-source
|
||||
library. This was made specifically for speaking with the annotation_storage_url and
|
||||
can be used and expanded, but not modified by anyone else needing such a process.
|
||||
'''
|
||||
from base64 import urlsafe_b64encode
|
||||
import hashlib
|
||||
import hmac
|
||||
import sys
|
||||
try:
|
||||
import json
|
||||
except ImportError:
|
||||
import simplejson as json
|
||||
|
||||
__all__ = ['create_token']
|
||||
|
||||
TOKEN_SEP = '.'
|
||||
|
||||
|
||||
def create_token(secret, data):
|
||||
'''
|
||||
Simply takes in the secret key and the data and
|
||||
passes it to the local function _encode_token
|
||||
'''
|
||||
return _encode_token(secret, data)
|
||||
|
||||
|
||||
if sys.version_info < (2, 7):
|
||||
def _encode(bytes_data):
|
||||
'''
|
||||
Takes a json object, string, or binary and
|
||||
uses python's urlsafe_b64encode to encode data
|
||||
and make it safe pass along in a url.
|
||||
To make sure it does not conflict with variables
|
||||
we make sure equal signs are removed.
|
||||
More info: docs.python.org/2/library/base64.html
|
||||
'''
|
||||
encoded = urlsafe_b64encode(bytes(bytes_data))
|
||||
return encoded.decode('utf-8').replace('=', '')
|
||||
else:
|
||||
def _encode(bytes_info):
|
||||
'''
|
||||
Same as above function but for Python 2.7 or later
|
||||
'''
|
||||
encoded = urlsafe_b64encode(bytes_info)
|
||||
return encoded.decode('utf-8').replace('=', '')
|
||||
|
||||
|
||||
def _encode_json(obj):
|
||||
'''
|
||||
Before a python dict object can be properly encoded,
|
||||
it must be transformed into a jason object and then
|
||||
transformed into bytes to be encoded using the function
|
||||
defined above.
|
||||
'''
|
||||
return _encode(bytearray(json.dumps(obj), 'utf-8'))
|
||||
|
||||
|
||||
def _sign(secret, to_sign):
|
||||
'''
|
||||
This function creates a sign that goes at the end of the
|
||||
message that is specific to the secret and not the actual
|
||||
content of the encoded body.
|
||||
More info on hashing: http://docs.python.org/2/library/hmac.html
|
||||
The function creates a hashed values of the secret and to_sign
|
||||
and returns the digested values based the secure hash
|
||||
algorithm, 256
|
||||
'''
|
||||
def portable_bytes(string):
|
||||
'''
|
||||
Simply transforms a string into a bytes object,
|
||||
which is a series of immutable integers 0<=x<=256.
|
||||
Always try to encode as utf-8, unless it is not
|
||||
compliant.
|
||||
'''
|
||||
try:
|
||||
return bytes(string, 'utf-8')
|
||||
except TypeError:
|
||||
return bytes(string)
|
||||
return _encode(hmac.new(portable_bytes(secret), portable_bytes(to_sign), hashlib.sha256).digest()) # pylint: disable=E1101
|
||||
|
||||
|
||||
def _encode_token(secret, claims):
|
||||
'''
|
||||
This is the main function that takes the secret token and
|
||||
the data to be transmitted. There is a header created for decoding
|
||||
purposes. Token_SEP means that a period/full stop separates the
|
||||
header, data object/message, and signatures.
|
||||
'''
|
||||
encoded_header = _encode_json({'typ': 'JWT', 'alg': 'HS256'})
|
||||
encoded_claims = _encode_json(claims)
|
||||
secure_bits = '%s%s%s' % (encoded_header, TOKEN_SEP, encoded_claims)
|
||||
sig = _sign(secret, secure_bits)
|
||||
return '%s%s%s' % (secure_bits, TOKEN_SEP, sig)
|
||||
@@ -879,10 +879,14 @@ class CourseEnrollment(models.Model):
|
||||
|
||||
`user` is a Django User object
|
||||
`course_id` is our usual course_id string (e.g. "edX/Test101/2013_Fall)
|
||||
|
||||
Returns the mode for both inactive and active users.
|
||||
Returns None if the courseenrollment record does not exist.
|
||||
"""
|
||||
try:
|
||||
record = CourseEnrollment.objects.get(user=user, course_id=course_id)
|
||||
if record.is_active:
|
||||
|
||||
if hasattr(record, 'mode'):
|
||||
return record.mode
|
||||
else:
|
||||
return None
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"""
|
||||
This test will run for firebase_token_generator.py.
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from student.firebase_token_generator import _encode, _encode_json, _encode_token, create_token
|
||||
|
||||
|
||||
class TokenGenerator(TestCase):
|
||||
"""
|
||||
Tests for the file firebase_token_generator.py
|
||||
"""
|
||||
def test_encode(self):
|
||||
"""
|
||||
This tests makes sure that no matter what version of python
|
||||
you have, the _encode function still returns the appropriate result
|
||||
for a string.
|
||||
"""
|
||||
expected = "dGVzdDE"
|
||||
result = _encode("test1")
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
def test_encode_json(self):
|
||||
"""
|
||||
Same as above, but this one focuses on a python dict type
|
||||
transformed into a json object and then encoded.
|
||||
"""
|
||||
expected = "eyJ0d28iOiAidGVzdDIiLCAib25lIjogInRlc3QxIn0"
|
||||
result = _encode_json({'one': 'test1', 'two': 'test2'})
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
def test_create_token(self):
|
||||
"""
|
||||
Unlike its counterpart in student/views.py, this function
|
||||
just checks for the encoding of a token. The other function
|
||||
will test depending on time and user.
|
||||
"""
|
||||
expected = "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJ1c2VySWQiOiAidXNlcm5hbWUiLCAidHRsIjogODY0MDB9.-p1sr7uwCapidTQ0qB7DdU2dbF-hViKpPNN_5vD10t8"
|
||||
result1 = _encode_token('4c7f4d1c-8ac4-4e9f-84c8-b271c57fcac4', {"userId": "username", "ttl": 86400})
|
||||
result2 = create_token('4c7f4d1c-8ac4-4e9f-84c8-b271c57fcac4', {"userId": "username", "ttl": 86400})
|
||||
self.assertEqual(expected, result1)
|
||||
self.assertEqual(expected, result2)
|
||||
@@ -27,7 +27,7 @@ from mock import Mock, patch
|
||||
|
||||
from student.models import anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment, unique_id_for_user
|
||||
from student.views import (process_survey_link, _cert_info,
|
||||
change_enrollment, complete_course_mode_info, token)
|
||||
change_enrollment, complete_course_mode_info)
|
||||
from student.tests.factories import UserFactory, CourseModeFactory
|
||||
|
||||
import shoppingcart
|
||||
@@ -491,26 +491,3 @@ class AnonymousLookupTable(TestCase):
|
||||
anonymous_id = anonymous_id_for_user(self.user, self.course.id)
|
||||
real_user = user_by_anonymous_id(anonymous_id)
|
||||
self.assertEqual(self.user, real_user)
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
|
||||
class Token(ModuleStoreTestCase):
|
||||
"""
|
||||
Test for the token generator. This creates a random course and passes it through the token file which generates the
|
||||
token that will be passed in to the annotation_storage_url.
|
||||
"""
|
||||
request_factory = RequestFactory()
|
||||
COURSE_SLUG = "100"
|
||||
COURSE_NAME = "test_course"
|
||||
COURSE_ORG = "edx"
|
||||
|
||||
def setUp(self):
|
||||
self.course = CourseFactory.create(org=self.COURSE_ORG, display_name=self.COURSE_NAME, number=self.COURSE_SLUG)
|
||||
self.user = User.objects.create(username="username", email="username")
|
||||
self.req = self.request_factory.post('/token?course_id=edx/100/test_course', {'user': self.user})
|
||||
self.req.user = self.user
|
||||
|
||||
def test_token(self):
|
||||
expected = HttpResponse("eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJpc3N1ZWRBdCI6ICIyMDE0LTAxLTIzVDE5OjM1OjE3LjUyMjEwNC01OjAwIiwgImNvbnN1bWVyS2V5IjogInh4eHh4eHh4LXh4eHgteHh4eC14eHh4LXh4eHh4eHh4eHh4eCIsICJ1c2VySWQiOiAidXNlcm5hbWUiLCAidHRsIjogODY0MDB9.OjWz9mzqJnYuzX-f3uCBllqJUa8PVWJjcDy_McfxLvc", mimetype="text/plain")
|
||||
response = token(self.req)
|
||||
self.assertEqual(expected.content.split('.')[0], response.content.split('.')[0])
|
||||
|
||||
@@ -44,7 +44,6 @@ from student.models import (
|
||||
create_comments_service_user, PasswordHistory
|
||||
)
|
||||
from student.forms import PasswordResetFormNoActive
|
||||
from student.firebase_token_generator import create_token
|
||||
|
||||
from verify_student.models import SoftwareSecurePhotoVerification, MidcourseReverificationWindow
|
||||
from certificates.models import CertificateStatuses, certificate_status_for_student
|
||||
@@ -1857,6 +1856,7 @@ def change_email_settings(request):
|
||||
track.views.server_track(request, "change-email-settings", {"receive_emails": "no", "course": course_id}, page='dashboard')
|
||||
|
||||
return JsonResponse({"success": True})
|
||||
<<<<<<< HEAD
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -1880,3 +1880,5 @@ def token(request):
|
||||
newtoken = create_token(secret, custom_data)
|
||||
response = HttpResponse(newtoken, mimetype="text/plain")
|
||||
return response
|
||||
=======
|
||||
>>>>>>> edx/master
|
||||
|
||||
@@ -1388,7 +1388,7 @@ class StringResponse(LoncapaResponse):
|
||||
result = re.search(regexp, given)
|
||||
except Exception as err:
|
||||
msg = u'[courseware.capa.responsetypes.stringresponse] {error}: {message}'.format(
|
||||
error=_(u'error'),
|
||||
error=_('error'),
|
||||
message=err.message
|
||||
)
|
||||
log.error(msg, exc_info=True)
|
||||
@@ -1415,7 +1415,8 @@ class StringResponse(LoncapaResponse):
|
||||
|
||||
def get_answers(self):
|
||||
_ = self.capa_system.i18n.ugettext
|
||||
separator = u' <b>{}</b> '.format(_(u'or'))
|
||||
# Translators: Separator used in StringResponse to display multiple answers. Example: "Answer: Answer_1 or Answer_2 or Answer_3".
|
||||
separator = u' <b>{}</b> '.format(_('or'))
|
||||
return {self.answer_id: separator.join(self.correct_answer)}
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
@@ -1521,17 +1522,18 @@ class CustomResponse(LoncapaResponse):
|
||||
# ordered list of answers
|
||||
submission = [student_answers[k] for k in idset]
|
||||
except Exception as err:
|
||||
msg = _(
|
||||
"[courseware.capa.responsetypes.customresponse] error getting"
|
||||
" student answer from {student_answers}"
|
||||
"\n idset = {idset}, error = {err}"
|
||||
).format(
|
||||
student_answers=student_answers,
|
||||
msg = u"[courseware.capa.responsetypes.customresponse] {message}\n idset = {idset}, error = {err}".format(
|
||||
message= _("error getting student answer from {student_answers}").format(student_answers=student_answers),
|
||||
idset=idset,
|
||||
err=err
|
||||
);
|
||||
)
|
||||
|
||||
log.error(msg)
|
||||
log.error(
|
||||
"[courseware.capa.responsetypes.customresponse] error getting"
|
||||
" student answer from %s"
|
||||
"\n idset = %s, error = %s",
|
||||
student_answers, idset, err
|
||||
)
|
||||
raise Exception(msg)
|
||||
|
||||
# global variable in context which holds the Presentation MathML from dynamic math input
|
||||
|
||||
@@ -20,6 +20,7 @@ from capa.responsetypes import LoncapaProblemError, \
|
||||
StudentInputError, ResponseError
|
||||
from capa.correctmap import CorrectMap
|
||||
from capa.util import convert_files_to_filenames
|
||||
from capa.util import compare_with_tolerance
|
||||
from capa.xqueue_interface import dateformat
|
||||
|
||||
from pytz import UTC
|
||||
@@ -1120,7 +1121,6 @@ class NumericalResponseTest(ResponseTest):
|
||||
# We blend the line between integration (using evaluator) and exclusively
|
||||
# unit testing the NumericalResponse (mocking out the evaluator)
|
||||
# For simple things its not worth the effort.
|
||||
|
||||
def test_grade_range_tolerance(self):
|
||||
problem_setup = [
|
||||
# [given_asnwer, [list of correct responses], [list of incorrect responses]]
|
||||
@@ -1177,9 +1177,20 @@ class NumericalResponseTest(ResponseTest):
|
||||
self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
|
||||
|
||||
def test_grade_percent_tolerance(self):
|
||||
# Positive only range
|
||||
problem = self.build_problem(answer=4, tolerance="10%")
|
||||
correct_responses = ["4.0", "4.3", "3.7", "4.30", "3.70"]
|
||||
incorrect_responses = ["", "4.5", "3.5", "0"]
|
||||
correct_responses = ["4.0", "4.00", "4.39", "3.61"]
|
||||
incorrect_responses = ["", "4.41", "3.59", "0"]
|
||||
self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
|
||||
# Negative only range
|
||||
problem = self.build_problem(answer=-4, tolerance="10%")
|
||||
correct_responses = ["-4.0", "-4.00", "-4.39", "-3.61"]
|
||||
incorrect_responses = ["", "-4.41", "-3.59", "0"]
|
||||
self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
|
||||
# Mixed negative/positive range
|
||||
problem = self.build_problem(answer=1, tolerance="200%")
|
||||
correct_responses = ["1", "1.00", "2.99", "0.99"]
|
||||
incorrect_responses = ["", "3.01", "-1.01"]
|
||||
self.assert_multiple_grade(problem, correct_responses, incorrect_responses)
|
||||
|
||||
def test_floats(self):
|
||||
|
||||
82
common/lib/capa/capa/tests/test_util.py
Normal file
82
common/lib/capa/capa/tests/test_util.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Tests capa util"""
|
||||
|
||||
import unittest
|
||||
import textwrap
|
||||
from . import test_capa_system
|
||||
from capa.util import compare_with_tolerance
|
||||
|
||||
|
||||
class UtilTest(unittest.TestCase):
|
||||
"""Tests for util"""
|
||||
def setUp(self):
|
||||
super(UtilTest, self).setUp()
|
||||
self.system = test_capa_system()
|
||||
|
||||
def test_compare_with_tolerance(self):
|
||||
# Test default tolerance '0.001%' (it is relative)
|
||||
result = compare_with_tolerance(100.0, 100.0)
|
||||
self.assertTrue(result)
|
||||
result = compare_with_tolerance(100.001, 100.0)
|
||||
self.assertTrue(result)
|
||||
result = compare_with_tolerance(101.0, 100.0)
|
||||
self.assertFalse(result)
|
||||
# Test absolute percentage tolerance
|
||||
result = compare_with_tolerance(109.9, 100.0, '10%', False)
|
||||
self.assertTrue(result)
|
||||
result = compare_with_tolerance(110.1, 100.0, '10%', False)
|
||||
self.assertFalse(result)
|
||||
# Test relative percentage tolerance
|
||||
result = compare_with_tolerance(111.0, 100.0, '10%', True)
|
||||
self.assertTrue(result)
|
||||
result = compare_with_tolerance(112.0, 100.0, '10%', True)
|
||||
self.assertFalse(result)
|
||||
# Test absolute tolerance (string)
|
||||
result = compare_with_tolerance(109.9, 100.0, '10.0', False)
|
||||
self.assertTrue(result)
|
||||
result = compare_with_tolerance(110.1, 100.0, '10.0', False)
|
||||
self.assertFalse(result)
|
||||
# Test relative tolerance (string)
|
||||
result = compare_with_tolerance(111.0, 100.0, '0.1', True)
|
||||
self.assertTrue(result)
|
||||
result = compare_with_tolerance(112.0, 100.0, '0.1', True)
|
||||
self.assertFalse(result)
|
||||
# Test absolute tolerance (float)
|
||||
result = compare_with_tolerance(109.9, 100.0, 10.0, False)
|
||||
self.assertTrue(result)
|
||||
result = compare_with_tolerance(110.1, 100.0, 10.0, False)
|
||||
self.assertFalse(result)
|
||||
# Test relative tolerance (float)
|
||||
result = compare_with_tolerance(111.0, 100.0, 0.1, True)
|
||||
self.assertTrue(result)
|
||||
result = compare_with_tolerance(112.0, 100.0, 0.1, True)
|
||||
self.assertFalse(result)
|
||||
##### Infinite values #####
|
||||
infinity = float('Inf')
|
||||
# Test relative tolerance (float)
|
||||
result = compare_with_tolerance(infinity, 100.0, 1.0, True)
|
||||
self.assertFalse(result)
|
||||
result = compare_with_tolerance(100.0, infinity, 1.0, True)
|
||||
self.assertFalse(result)
|
||||
result = compare_with_tolerance(infinity, infinity, 1.0, True)
|
||||
self.assertTrue(result)
|
||||
# Test absolute tolerance (float)
|
||||
result = compare_with_tolerance(infinity, 100.0, 1.0, False)
|
||||
self.assertFalse(result)
|
||||
result = compare_with_tolerance(100.0, infinity, 1.0, False)
|
||||
self.assertFalse(result)
|
||||
result = compare_with_tolerance(infinity, infinity, 1.0, False)
|
||||
self.assertTrue(result)
|
||||
# Test relative tolerance (string)
|
||||
result = compare_with_tolerance(infinity, 100.0, '1.0', True)
|
||||
self.assertFalse(result)
|
||||
result = compare_with_tolerance(100.0, infinity, '1.0', True)
|
||||
self.assertFalse(result)
|
||||
result = compare_with_tolerance(infinity, infinity, '1.0', True)
|
||||
self.assertTrue(result)
|
||||
# Test absolute tolerance (string)
|
||||
result = compare_with_tolerance(infinity, 100.0, '1.0', False)
|
||||
self.assertFalse(result)
|
||||
result = compare_with_tolerance(100.0, infinity, '1.0', False)
|
||||
self.assertFalse(result)
|
||||
result = compare_with_tolerance(infinity, infinity, '1.0', False)
|
||||
self.assertTrue(result)
|
||||
@@ -7,16 +7,29 @@ from cmath import isinf
|
||||
default_tolerance = '0.001%'
|
||||
|
||||
|
||||
def compare_with_tolerance(complex1, complex2, tolerance=default_tolerance, relative_tolerance=False):
|
||||
def compare_with_tolerance(student_complex, instructor_complex, tolerance=default_tolerance, relative_tolerance=False):
|
||||
"""
|
||||
Compare complex1 to complex2 with maximum tolerance tol.
|
||||
Compare student_complex to instructor_complex with maximum tolerance tolerance.
|
||||
|
||||
If tolerance is type string, then it is counted as relative if it ends in %; otherwise, it is absolute.
|
||||
- student_complex : student result (float complex number)
|
||||
- instructor_complex : instructor result (float complex number)
|
||||
- tolerance : float, or string (representing a float or a percentage)
|
||||
- relative_tolerance: bool, to explicitly use passed tolerance as relative
|
||||
|
||||
- complex1 : student result (float complex number)
|
||||
- complex2 : instructor result (float complex number)
|
||||
- tolerance : string representing a number or float
|
||||
- relative_tolerance: bool, used when`tolerance` is float to explicitly use passed tolerance as relative.
|
||||
Note: when a tolerance is a percentage (i.e. '10%'), it will compute that
|
||||
percentage of the instructor result and yield a number.
|
||||
|
||||
If relative_tolerance is set to False, it will use that value and the
|
||||
instructor result to define the bounds of valid student result:
|
||||
instructor_complex = 10, tolerance = '10%' will give [9.0, 11.0].
|
||||
|
||||
If relative_tolerance is set to True, it will use that value and both
|
||||
instructor result and student result to define the bounds of valid student
|
||||
result:
|
||||
instructor_complex = 10, student_complex = 20, tolerance = '10%' will give
|
||||
[8.0, 12.0].
|
||||
This is typically used internally to compare float, with a
|
||||
default_tolerance = '0.001%'.
|
||||
|
||||
Default tolerance of 1e-3% is added to compare two floats for
|
||||
near-equality (to handle machine representation errors).
|
||||
@@ -29,23 +42,28 @@ def compare_with_tolerance(complex1, complex2, tolerance=default_tolerance, rela
|
||||
In [212]: 1.9e24 - 1.9*10**24
|
||||
Out[212]: 268435456.0
|
||||
"""
|
||||
if relative_tolerance:
|
||||
tolerance = tolerance * max(abs(complex1), abs(complex2))
|
||||
elif tolerance.endswith('%'):
|
||||
tolerance = evaluator(dict(), dict(), tolerance[:-1]) * 0.01
|
||||
tolerance = tolerance * max(abs(complex1), abs(complex2))
|
||||
else:
|
||||
tolerance = evaluator(dict(), dict(), tolerance)
|
||||
if isinstance(tolerance, str):
|
||||
if tolerance == default_tolerance:
|
||||
relative_tolerance = True
|
||||
if tolerance.endswith('%'):
|
||||
tolerance = evaluator(dict(), dict(), tolerance[:-1]) * 0.01
|
||||
if not relative_tolerance:
|
||||
tolerance = tolerance * abs(instructor_complex)
|
||||
else:
|
||||
tolerance = evaluator(dict(), dict(), tolerance)
|
||||
|
||||
if isinf(complex1) or isinf(complex2):
|
||||
# If an input is infinite, we can end up with `abs(complex1-complex2)` and
|
||||
if relative_tolerance:
|
||||
tolerance = tolerance * max(abs(student_complex), abs(instructor_complex))
|
||||
|
||||
if isinf(student_complex) or isinf(instructor_complex):
|
||||
# If an input is infinite, we can end up with `abs(student_complex-instructor_complex)` and
|
||||
# `tolerance` both equal to infinity. Then, below we would have
|
||||
# `inf <= inf` which is a fail. Instead, compare directly.
|
||||
return complex1 == complex2
|
||||
return student_complex == instructor_complex
|
||||
else:
|
||||
# v1 and v2 are, in general, complex numbers:
|
||||
# there are some notes about backward compatibility issue: see responsetypes.get_staff_ans()).
|
||||
return abs(complex1 - complex2) <= tolerance
|
||||
return abs(student_complex - instructor_complex) <= tolerance
|
||||
|
||||
|
||||
def contextualize_text(text, context): # private
|
||||
|
||||
32
common/lib/xmodule/xmodule/annotator_token.py
Normal file
32
common/lib/xmodule/xmodule/annotator_token.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
This file contains a function used to retrieve the token for the annotation backend
|
||||
without having to create a view, but just returning a string instead.
|
||||
|
||||
It can be called from other files by using the following:
|
||||
from xmodule.annotator_token import retrieve_token
|
||||
"""
|
||||
import datetime
|
||||
from firebase_token_generator import create_token
|
||||
|
||||
|
||||
def retrieve_token(userid, secret):
|
||||
'''
|
||||
Return a token for the backend of annotations.
|
||||
It uses the course id to retrieve a variable that contains the secret
|
||||
token found in inheritance.py. It also contains information of when
|
||||
the token was issued. This will be stored with the user along with
|
||||
the id for identification purposes in the backend.
|
||||
'''
|
||||
|
||||
# the following five lines of code allows you to include the default timezone in the iso format
|
||||
# for more information: http://stackoverflow.com/questions/3401428/how-to-get-an-isoformat-datetime-string-including-the-default-timezone
|
||||
dtnow = datetime.datetime.now()
|
||||
dtutcnow = datetime.datetime.utcnow()
|
||||
delta = dtnow - dtutcnow
|
||||
newhour, newmin = divmod((delta.days * 24 * 60 * 60 + delta.seconds + 30) // 60, 60)
|
||||
newtime = "%s%+02d:%02d" % (dtnow.isoformat(), newhour, newmin)
|
||||
# uses the issued time (UTC plus timezone), the consumer key and the user's email to maintain a
|
||||
# federated system in the annotation backend server
|
||||
custom_data = {"issuedAt": newtime, "consumerKey": secret, "userId": userid, "ttl": 86400}
|
||||
newtoken = create_token(secret, custom_data)
|
||||
return newtoken
|
||||
@@ -301,6 +301,7 @@ function (VideoPlayer, VideoStorage) {
|
||||
// TODO: use 1 class to work with.
|
||||
state.el.find('.video-player div').addClass('hidden');
|
||||
state.el.find('.video-player h3').removeClass('hidden');
|
||||
_hideWaitPlaceholder(state);
|
||||
|
||||
console.log(
|
||||
'[Video info]: Non-youtube video sources aren\'t available.'
|
||||
@@ -673,6 +674,15 @@ function (VideoPlayer, VideoStorage) {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// None of the supported video formats can be played. Hide the spinner.
|
||||
if (!(_.compact(_.values(this.html5Sources)))) {
|
||||
_hideWaitPlaceholder(state);
|
||||
console.log(
|
||||
'[Video info]: This browser cannot play .mp4, .ogg, or .webm ' +
|
||||
'files'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// function fetchMetadata()
|
||||
|
||||
@@ -216,7 +216,10 @@ function () {
|
||||
// video element via jquery (http://bugs.jquery.com/ticket/9174) we
|
||||
// create it using native JS.
|
||||
this.video = document.createElement('video');
|
||||
this.video.innerHTML = _.values(sourceStr).join('');
|
||||
errorMessage = gettext('This browser cannot play .mp4, .ogg, or ' +
|
||||
'.webm files. Try using a different browser, such as Google ' +
|
||||
'Chrome.');
|
||||
this.video.innerHTML = _.values(sourceStr).join('') + errorMessage;
|
||||
|
||||
// Get the jQuery object, and set the player state to UNSTARTED.
|
||||
// The player state is used by other parts of the VideoPlayer to
|
||||
|
||||
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,
|
||||
}))
|
||||
20
common/lib/xmodule/xmodule/tests/test_annotator_token.py
Normal file
20
common/lib/xmodule/xmodule/tests/test_annotator_token.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
This test will run for annotator_token.py
|
||||
"""
|
||||
import unittest
|
||||
|
||||
from xmodule.annotator_token import retrieve_token
|
||||
|
||||
|
||||
class TokenRetriever(unittest.TestCase):
|
||||
"""
|
||||
Tests to make sure that when passed in a username and secret token, that it will be encoded correctly
|
||||
"""
|
||||
def test_token(self):
|
||||
"""
|
||||
Test for the token generator. Give an a random username and secret token, it should create the properly encoded string of text.
|
||||
"""
|
||||
expected = "eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXVCJ9.eyJpc3N1ZWRBdCI6ICIyMDE0LTAyLTI3VDE3OjAwOjQyLjQwNjQ0MSswOjAwIiwgImNvbnN1bWVyS2V5IjogImZha2Vfc2VjcmV0IiwgInVzZXJJZCI6ICJ1c2VybmFtZSIsICJ0dGwiOiA4NjQwMH0.Dx1PoF-7mqBOOSGDMZ9R_s3oaaLRPnn6CJgGGF2A5CQ"
|
||||
response = retrieve_token("username", "fake_secret")
|
||||
self.assertEqual(expected.split('.')[0], response.split('.')[0])
|
||||
self.assertNotEqual(expected.split('.')[2], response.split('.')[2])
|
||||
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)
|
||||
@@ -38,17 +38,6 @@ class TextAnnotationModuleTestCase(unittest.TestCase):
|
||||
ScopeIds(None, None, None, None)
|
||||
)
|
||||
|
||||
def test_render_content(self):
|
||||
"""
|
||||
Tests to make sure the sample xml is rendered and that it forms a valid xmltree
|
||||
that does not contain a display_name.
|
||||
"""
|
||||
content = self.mod._render_content() # pylint: disable=W0212
|
||||
self.assertIsNotNone(content)
|
||||
element = etree.fromstring(content)
|
||||
self.assertIsNotNone(element)
|
||||
self.assertFalse('display_name' in element.attrib, "Display Name should have been deleted from Content")
|
||||
|
||||
def test_extract_instructions(self):
|
||||
"""
|
||||
Tests to make sure that the instructions are correctly pulled from the sample xml above.
|
||||
@@ -70,5 +59,5 @@ class TextAnnotationModuleTestCase(unittest.TestCase):
|
||||
Tests the function that passes in all the information in the context that will be used in templates/textannotation.html
|
||||
"""
|
||||
context = self.mod.get_html()
|
||||
for key in ['display_name', 'tag', 'source', 'instructions_html', 'content_html', 'annotation_storage']:
|
||||
for key in ['display_name', 'tag', 'source', 'instructions_html', 'content_html', 'annotation_storage', 'token']:
|
||||
self.assertIn(key, context)
|
||||
|
||||
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)
|
||||
@@ -34,100 +34,6 @@ class VideoAnnotationModuleTestCase(unittest.TestCase):
|
||||
ScopeIds(None, None, None, None)
|
||||
)
|
||||
|
||||
def test_annotation_class_attr_default(self):
|
||||
"""
|
||||
Makes sure that it can detect annotation values in text-form if user
|
||||
decides to add text to the area below video, video functionality is completely
|
||||
found in javascript.
|
||||
"""
|
||||
xml = '<annotation title="x" body="y" problem="0">test</annotation>'
|
||||
element = etree.fromstring(xml)
|
||||
|
||||
expected_attr = {'class': {'value': 'annotatable-span highlight'}}
|
||||
actual_attr = self.mod._get_annotation_class_attr(element) # pylint: disable=W0212
|
||||
|
||||
self.assertIsInstance(actual_attr, dict)
|
||||
self.assertDictEqual(expected_attr, actual_attr)
|
||||
|
||||
def test_annotation_class_attr_with_valid_highlight(self):
|
||||
"""
|
||||
Same as above but more specific to an area that is highlightable in the appropriate
|
||||
color designated.
|
||||
"""
|
||||
xml = '<annotation title="x" body="y" problem="0" highlight="{highlight}">test</annotation>'
|
||||
|
||||
for color in self.mod.highlight_colors:
|
||||
element = etree.fromstring(xml.format(highlight=color))
|
||||
value = 'annotatable-span highlight highlight-{highlight}'.format(highlight=color)
|
||||
|
||||
expected_attr = {'class': {
|
||||
'value': value,
|
||||
'_delete': 'highlight'}
|
||||
}
|
||||
actual_attr = self.mod._get_annotation_class_attr(element) # pylint: disable=W0212
|
||||
|
||||
self.assertIsInstance(actual_attr, dict)
|
||||
self.assertDictEqual(expected_attr, actual_attr)
|
||||
|
||||
def test_annotation_class_attr_with_invalid_highlight(self):
|
||||
"""
|
||||
Same as above, but checked with invalid colors.
|
||||
"""
|
||||
xml = '<annotation title="x" body="y" problem="0" highlight="{highlight}">test</annotation>'
|
||||
|
||||
for invalid_color in ['rainbow', 'blink', 'invisible', '', None]:
|
||||
element = etree.fromstring(xml.format(highlight=invalid_color))
|
||||
expected_attr = {'class': {
|
||||
'value': 'annotatable-span highlight',
|
||||
'_delete': 'highlight'}
|
||||
}
|
||||
actual_attr = self.mod._get_annotation_class_attr(element) # pylint: disable=W0212
|
||||
|
||||
self.assertIsInstance(actual_attr, dict)
|
||||
self.assertDictEqual(expected_attr, actual_attr)
|
||||
|
||||
def test_annotation_data_attr(self):
|
||||
"""
|
||||
Test that each highlight contains the data information from the annotation itself.
|
||||
"""
|
||||
element = etree.fromstring('<annotation title="bar" body="foo" problem="0">test</annotation>')
|
||||
|
||||
expected_attr = {
|
||||
'data-comment-body': {'value': 'foo', '_delete': 'body'},
|
||||
'data-comment-title': {'value': 'bar', '_delete': 'title'},
|
||||
'data-problem-id': {'value': '0', '_delete': 'problem'}
|
||||
}
|
||||
|
||||
actual_attr = self.mod._get_annotation_data_attr(element) # pylint: disable=W0212
|
||||
|
||||
self.assertIsInstance(actual_attr, dict)
|
||||
self.assertDictEqual(expected_attr, actual_attr)
|
||||
|
||||
def test_render_annotation(self):
|
||||
"""
|
||||
Tests to make sure that the spans designating annotations acutally visually render as annotations.
|
||||
"""
|
||||
expected_html = '<span class="annotatable-span highlight highlight-yellow" data-comment-title="x" data-comment-body="y" data-problem-id="0">z</span>'
|
||||
expected_el = etree.fromstring(expected_html)
|
||||
|
||||
actual_el = etree.fromstring('<annotation title="x" body="y" problem="0" highlight="yellow">z</annotation>')
|
||||
self.mod._render_annotation(actual_el) # pylint: disable=W0212
|
||||
|
||||
self.assertEqual(expected_el.tag, actual_el.tag)
|
||||
self.assertEqual(expected_el.text, actual_el.text)
|
||||
self.assertDictEqual(dict(expected_el.attrib), dict(actual_el.attrib))
|
||||
|
||||
def test_render_content(self):
|
||||
"""
|
||||
Like above, but using the entire text, it makes sure that display_name is removed and that there is only one
|
||||
div encompassing the annotatable area.
|
||||
"""
|
||||
content = self.mod._render_content() # pylint: disable=W0212
|
||||
element = etree.fromstring(content)
|
||||
self.assertIsNotNone(element)
|
||||
self.assertEqual('div', element.tag, 'root tag is a div')
|
||||
self.assertFalse('display_name' in element.attrib, "Display Name should have been deleted from Content")
|
||||
|
||||
def test_extract_instructions(self):
|
||||
"""
|
||||
This test ensures that if an instruction exists it is pulled and
|
||||
@@ -160,6 +66,6 @@ class VideoAnnotationModuleTestCase(unittest.TestCase):
|
||||
"""
|
||||
Tests to make sure variables passed in truly exist within the html once it is all rendered.
|
||||
"""
|
||||
context = self.mod.get_html()
|
||||
for key in ['display_name', 'content_html', 'instructions_html', 'sourceUrl', 'typeSource', 'poster', 'alert', 'annotation_storage']:
|
||||
context = self.mod.get_html() # pylint: disable=W0212
|
||||
for key in ['display_name', 'instructions_html', 'sourceUrl', 'typeSource', 'poster', 'annotation_storage']:
|
||||
self.assertIn(key, context)
|
||||
|
||||
@@ -6,6 +6,7 @@ from pkg_resources import resource_string
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from xblock.core import Scope, String
|
||||
from xmodule.annotator_token import retrieve_token
|
||||
|
||||
import textwrap
|
||||
|
||||
@@ -30,7 +31,7 @@ class AnnotatableFields(object):
|
||||
scope=Scope.settings,
|
||||
default='Text Annotation',
|
||||
)
|
||||
tags = String(
|
||||
instructor_tags = String(
|
||||
display_name="Tags for Assignments",
|
||||
help="Add tags that automatically highlight in a certain color using the comma-separated form, i.e. imagery:red,parallelism:blue",
|
||||
scope=Scope.settings,
|
||||
@@ -43,6 +44,7 @@ class AnnotatableFields(object):
|
||||
default='None',
|
||||
)
|
||||
annotation_storage_url = String(help="Location of Annotation backend", scope=Scope.settings, default="http://your_annotation_storage.com", display_name="Url for Annotation Storage")
|
||||
annotation_token_secret = String(help="Secret string for annotation storage", scope=Scope.settings, default="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", display_name="Secret Token String for Annotation")
|
||||
|
||||
|
||||
class TextAnnotationModule(AnnotatableFields, XModule):
|
||||
@@ -59,15 +61,9 @@ class TextAnnotationModule(AnnotatableFields, XModule):
|
||||
|
||||
self.instructions = self._extract_instructions(xmltree)
|
||||
self.content = etree.tostring(xmltree, encoding='unicode')
|
||||
self.highlight_colors = ['yellow', 'orange', 'purple', 'blue', 'green']
|
||||
|
||||
def _render_content(self):
|
||||
""" Renders annotatable content with annotation spans and returns HTML. """
|
||||
xmltree = etree.fromstring(self.content)
|
||||
if 'display_name' in xmltree.attrib:
|
||||
del xmltree.attrib['display_name']
|
||||
|
||||
return etree.tostring(xmltree, encoding='unicode')
|
||||
self.user_email = ""
|
||||
if self.runtime.get_real_user is not None:
|
||||
self.user_email = self.runtime.get_real_user(self.runtime.anonymous_student_id).email
|
||||
|
||||
def _extract_instructions(self, xmltree):
|
||||
""" Removes <instructions> from the xmltree and returns them as a string, otherwise None. """
|
||||
@@ -83,13 +79,13 @@ class TextAnnotationModule(AnnotatableFields, XModule):
|
||||
context = {
|
||||
'course_key': self.runtime.course_id,
|
||||
'display_name': self.display_name_with_default,
|
||||
'tag': self.tags,
|
||||
'tag': self.instructor_tags,
|
||||
'source': self.source,
|
||||
'instructions_html': self.instructions,
|
||||
'content_html': self._render_content(),
|
||||
'annotation_storage': self.annotation_storage_url
|
||||
'content_html': self.content,
|
||||
'annotation_storage': self.annotation_storage_url,
|
||||
'token': retrieve_token(self.user_email, self.annotation_token_secret),
|
||||
}
|
||||
|
||||
return self.system.render_template('textannotation.html', context)
|
||||
|
||||
|
||||
@@ -102,6 +98,7 @@ class TextAnnotationDescriptor(AnnotatableFields, RawDescriptor):
|
||||
def non_editable_metadata_fields(self):
|
||||
non_editable_fields = super(TextAnnotationDescriptor, self).non_editable_metadata_fields
|
||||
non_editable_fields.extend([
|
||||
TextAnnotationDescriptor.annotation_storage_url
|
||||
TextAnnotationDescriptor.annotation_storage_url,
|
||||
TextAnnotationDescriptor.annotation_token_secret,
|
||||
])
|
||||
return non_editable_fields
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -44,6 +44,7 @@ def get_ext(filename):
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
_ = lambda text: text
|
||||
|
||||
|
||||
class VideoModule(VideoFields, VideoStudentViewHandlers, XModule):
|
||||
@@ -177,12 +178,12 @@ class VideoDescriptor(VideoFields, VideoStudioViewHandlers, TabsEditingDescripto
|
||||
|
||||
tabs = [
|
||||
{
|
||||
'name': "Basic",
|
||||
'name': _("Basic"),
|
||||
'template': "video/transcripts.html",
|
||||
'current': True
|
||||
},
|
||||
{
|
||||
'name': "Advanced",
|
||||
'name': _("Advanced"),
|
||||
'template': "tabs/metadata-edit-tab.html"
|
||||
}
|
||||
]
|
||||
@@ -358,7 +359,7 @@ class VideoDescriptor(VideoFields, VideoStudioViewHandlers, TabsEditingDescripto
|
||||
_ = self.runtime.service(self, "i18n").ugettext
|
||||
video_url.update({
|
||||
'help': _('The URL for your video. This can be a YouTube URL or a link to an .mp4, .ogg, or .webm video file hosted elsewhere on the Internet.'),
|
||||
'display_name': 'Default Video URL',
|
||||
'display_name': _('Default Video URL'),
|
||||
'field_name': 'video_url',
|
||||
'type': 'VideoList',
|
||||
'default_value': [get_youtube_link(youtube_id_1_0['default_value'])]
|
||||
|
||||
@@ -14,51 +14,52 @@ _ = lambda text: text
|
||||
class VideoFields(object):
|
||||
"""Fields for `VideoModule` and `VideoDescriptor`."""
|
||||
display_name = String(
|
||||
display_name="Component Display Name", help="The name students see. This name appears in the course ribbon and as a header for the video.",
|
||||
help=_("The name students see. This name appears in the course ribbon and as a header for the video."),
|
||||
display_name=_("Component Display Name"),
|
||||
default="Video",
|
||||
scope=Scope.settings
|
||||
)
|
||||
|
||||
saved_video_position = RelativeTime(
|
||||
help="Current position in the video.",
|
||||
help=_("Current position in the video."),
|
||||
scope=Scope.user_state,
|
||||
default=datetime.timedelta(seconds=0)
|
||||
)
|
||||
# TODO: This should be moved to Scope.content, but this will
|
||||
# require data migration to support the old video module.
|
||||
youtube_id_1_0 = String(
|
||||
help="Optional, for older browsers: the YouTube ID for the normal speed video.",
|
||||
display_name="YouTube ID",
|
||||
help=_("Optional, for older browsers: the YouTube ID for the normal speed video."),
|
||||
display_name=_("YouTube ID"),
|
||||
scope=Scope.settings,
|
||||
default="OEoXaMPEzfM"
|
||||
)
|
||||
youtube_id_0_75 = String(
|
||||
help="Optional, for older browsers: the YouTube ID for the .75x speed video.",
|
||||
display_name="YouTube ID for .75x speed",
|
||||
help=_("Optional, for older browsers: the YouTube ID for the .75x speed video."),
|
||||
display_name=_("YouTube ID for .75x speed"),
|
||||
scope=Scope.settings,
|
||||
default=""
|
||||
)
|
||||
youtube_id_1_25 = String(
|
||||
help="Optional, for older browsers: the YouTube ID for the 1.25x speed video.",
|
||||
display_name="YouTube ID for 1.25x speed",
|
||||
help=_("Optional, for older browsers: the YouTube ID for the 1.25x speed video."),
|
||||
display_name=_("YouTube ID for 1.25x speed"),
|
||||
scope=Scope.settings,
|
||||
default=""
|
||||
)
|
||||
youtube_id_1_5 = String(
|
||||
help="Optional, for older browsers: the YouTube ID for the 1.5x speed video.",
|
||||
display_name="YouTube ID for 1.5x speed",
|
||||
help=_("Optional, for older browsers: the YouTube ID for the 1.5x speed video."),
|
||||
display_name=_("YouTube ID for 1.5x speed"),
|
||||
scope=Scope.settings,
|
||||
default=""
|
||||
)
|
||||
start_time = RelativeTime( # datetime.timedelta object
|
||||
help="Time you want the video to start if you don't want the entire video to play. Formatted as HH:MM:SS. The maximum value is 23:59:59.",
|
||||
display_name="Video Start Time",
|
||||
help=_("Time you want the video to start if you don't want the entire video to play. Formatted as HH:MM:SS. The maximum value is 23:59:59."),
|
||||
display_name=_("Video Start Time"),
|
||||
scope=Scope.settings,
|
||||
default=datetime.timedelta(seconds=0)
|
||||
)
|
||||
end_time = RelativeTime( # datetime.timedelta object
|
||||
help="Time you want the video to stop if you don't want the entire video to play. Formatted as HH:MM:SS. The maximum value is 23:59:59.",
|
||||
display_name="Video Stop Time",
|
||||
help=_("Time you want the video to stop if you don't want the entire video to play. Formatted as HH:MM:SS. The maximum value is 23:59:59."),
|
||||
display_name=_("Video Stop Time"),
|
||||
scope=Scope.settings,
|
||||
default=datetime.timedelta(seconds=0)
|
||||
)
|
||||
@@ -67,61 +68,61 @@ class VideoFields(object):
|
||||
# `source` is deprecated field and should not be used in future.
|
||||
# `download_video` is used instead.
|
||||
source = String(
|
||||
help="The external URL to download the video.",
|
||||
display_name="Download Video",
|
||||
help=_("The external URL to download the video."),
|
||||
display_name=_("Download Video"),
|
||||
scope=Scope.settings,
|
||||
default=""
|
||||
)
|
||||
download_video = Boolean(
|
||||
help="Allow students to download versions of this video in different formats if they cannot use the edX video player or do not have access to YouTube. You must add at least one non-YouTube URL in the Video File URLs field.",
|
||||
display_name="Video Download Allowed",
|
||||
help=_("Allow students to download versions of this video in different formats if they cannot use the edX video player or do not have access to YouTube. You must add at least one non-YouTube URL in the Video File URLs field."),
|
||||
display_name=_("Video Download Allowed"),
|
||||
scope=Scope.settings,
|
||||
default=False
|
||||
)
|
||||
html5_sources = List(
|
||||
help="The URL or URLs where you've posted non-YouTube versions of the video. Each URL must end in .mpeg, .mp4, .ogg, or .webm and cannot be a YouTube URL. Students will be able to view the first listed video that's compatible with the student's computer. To allow students to download these videos, set Video Download Allowed to True.",
|
||||
display_name="Video File URLs",
|
||||
help=_("The URL or URLs where you've posted non-YouTube versions of the video. Each URL must end in .mpeg, .mp4, .ogg, or .webm and cannot be a YouTube URL. Students will be able to view the first listed video that's compatible with the student's computer. To allow students to download these videos, set Video Download Allowed to True."),
|
||||
display_name=_("Video File URLs"),
|
||||
scope=Scope.settings,
|
||||
)
|
||||
track = String(
|
||||
help="By default, students can download an .srt or .txt transcript when you set Download Transcript Allowed to True. If you want to provide a downloadable transcript in a different format, we recommend that you upload a handout by using the Upload a Handout field. If this isn't possible, you can post a transcript file on the Files & Uploads page or on the Internet, and then add the URL for the transcript here. Students see a link to download that transcript below the video.",
|
||||
display_name="Downloadable Transcript URL",
|
||||
help=_("By default, students can download an .srt or .txt transcript when you set Download Transcript Allowed to True. If you want to provide a downloadable transcript in a different format, we recommend that you upload a handout by using the Upload a Handout field. If this isn't possible, you can post a transcript file on the Files & Uploads page or on the Internet, and then add the URL for the transcript here. Students see a link to download that transcript below the video."),
|
||||
display_name=_("Downloadable Transcript URL"),
|
||||
scope=Scope.settings,
|
||||
default=''
|
||||
)
|
||||
download_track = Boolean(
|
||||
help="Allow students to download the timed transcript. A link to download the file appears below the video. By default, the transcript is an .srt or .txt file. If you want to provide the transcript for download in a different format, upload a file by using the Upload Handout field.",
|
||||
display_name="Download Transcript Allowed",
|
||||
help=_("Allow students to download the timed transcript. A link to download the file appears below the video. By default, the transcript is an .srt or .txt file. If you want to provide the transcript for download in a different format, upload a file by using the Upload Handout field."),
|
||||
display_name=_("Download Transcript Allowed"),
|
||||
scope=Scope.settings,
|
||||
default=False
|
||||
)
|
||||
sub = String(
|
||||
help="The default transcript for the video, from the Default Timed Transcript field on the Basic tab. This transcript should be in English. You don't have to change this setting.",
|
||||
display_name="Default Timed Transcript",
|
||||
help=_("The default transcript for the video, from the Default Timed Transcript field on the Basic tab. This transcript should be in English. You don't have to change this setting."),
|
||||
display_name=_("Default Timed Transcript"),
|
||||
scope=Scope.settings,
|
||||
default=""
|
||||
)
|
||||
show_captions = Boolean(
|
||||
help="Specify whether the transcripts appear with the video by default.",
|
||||
display_name="Show Transcript",
|
||||
help=_("Specify whether the transcripts appear with the video by default."),
|
||||
display_name=_("Show Transcript"),
|
||||
scope=Scope.settings,
|
||||
default=True
|
||||
)
|
||||
# Data format: {'de': 'german_translation', 'uk': 'ukrainian_translation'}
|
||||
transcripts = Dict(
|
||||
help="Add transcripts in different languages. Click below to specify a language and upload an .srt transcript file for that language.",
|
||||
display_name="Transcript Languages",
|
||||
help=_("Add transcripts in different languages. Click below to specify a language and upload an .srt transcript file for that language."),
|
||||
display_name=_("Transcript Languages"),
|
||||
scope=Scope.settings,
|
||||
default={}
|
||||
)
|
||||
transcript_language = String(
|
||||
help="Preferred language for transcript.",
|
||||
display_name="Preferred language for transcript",
|
||||
help=_("Preferred language for transcript."),
|
||||
display_name=_("Preferred language for transcript"),
|
||||
scope=Scope.preferences,
|
||||
default="en"
|
||||
)
|
||||
transcript_download_format = String(
|
||||
help="Transcript file format to download by user.",
|
||||
help=_("Transcript file format to download by user."),
|
||||
scope=Scope.preferences,
|
||||
values=[
|
||||
# Translators: This is a type of file used for captioning in the video player.
|
||||
@@ -131,22 +132,22 @@ class VideoFields(object):
|
||||
default='srt',
|
||||
)
|
||||
speed = Float(
|
||||
help="The last speed that the user specified for the video.",
|
||||
help=_("The last speed that the user specified for the video."),
|
||||
scope=Scope.user_state,
|
||||
)
|
||||
global_speed = Float(
|
||||
help="The default speed for the video.",
|
||||
help=_("The default speed for the video."),
|
||||
scope=Scope.preferences,
|
||||
default=1.0
|
||||
)
|
||||
youtube_is_available = Boolean(
|
||||
help="Specify whether YouTube is available for the user.",
|
||||
help=_("Specify whether YouTube is available for the user."),
|
||||
scope=Scope.user_info,
|
||||
default=True
|
||||
)
|
||||
|
||||
handout = String(
|
||||
help="Upload a handout to accompany this video. Students can download the handout by clicking Download Handout under the video.",
|
||||
display_name="Upload Handout",
|
||||
help=_("Upload a handout to accompany this video. Students can download the handout by clicking Download Handout under the video."),
|
||||
display_name=_("Upload Handout"),
|
||||
scope=Scope.settings,
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ from pkg_resources import resource_string
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from xblock.core import Scope, String
|
||||
from xmodule.annotator_token import retrieve_token
|
||||
|
||||
import textwrap
|
||||
|
||||
@@ -31,7 +32,7 @@ class AnnotatableFields(object):
|
||||
sourceurl = String(help="The external source URL for the video.", display_name="Source URL", scope=Scope.settings, default="http://video-js.zencoder.com/oceans-clip.mp4")
|
||||
poster_url = String(help="Poster Image URL", display_name="Poster URL", scope=Scope.settings, default="")
|
||||
annotation_storage_url = String(help="Location of Annotation backend", scope=Scope.settings, default="http://your_annotation_storage.com", display_name="Url for Annotation Storage")
|
||||
|
||||
annotation_token_secret = String(help="Secret string for annotation storage", scope=Scope.settings, default="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", display_name="Secret Token String for Annotation")
|
||||
|
||||
class VideoAnnotationModule(AnnotatableFields, XModule):
|
||||
'''Video Annotation Module'''
|
||||
@@ -55,73 +56,9 @@ class VideoAnnotationModule(AnnotatableFields, XModule):
|
||||
|
||||
self.instructions = self._extract_instructions(xmltree)
|
||||
self.content = etree.tostring(xmltree, encoding='unicode')
|
||||
self.highlight_colors = ['yellow', 'orange', 'purple', 'blue', 'green']
|
||||
|
||||
def _get_annotation_class_attr(self, element):
|
||||
""" Returns a dict with the CSS class attribute to set on the annotation
|
||||
and an XML key to delete from the element.
|
||||
"""
|
||||
|
||||
attr = {}
|
||||
cls = ['annotatable-span', 'highlight']
|
||||
highlight_key = 'highlight'
|
||||
color = element.get(highlight_key)
|
||||
|
||||
if color is not None:
|
||||
if color in self.highlight_colors:
|
||||
cls.append('highlight-' + color)
|
||||
attr['_delete'] = highlight_key
|
||||
attr['value'] = ' '.join(cls)
|
||||
|
||||
return {'class': attr}
|
||||
|
||||
def _get_annotation_data_attr(self, element):
|
||||
""" Returns a dict in which the keys are the HTML data attributes
|
||||
to set on the annotation element. Each data attribute has a
|
||||
corresponding 'value' and (optional) '_delete' key to specify
|
||||
an XML attribute to delete.
|
||||
"""
|
||||
|
||||
data_attrs = {}
|
||||
attrs_map = {
|
||||
'body': 'data-comment-body',
|
||||
'title': 'data-comment-title',
|
||||
'problem': 'data-problem-id'
|
||||
}
|
||||
|
||||
for xml_key in attrs_map.keys():
|
||||
if xml_key in element.attrib:
|
||||
value = element.get(xml_key, '')
|
||||
html_key = attrs_map[xml_key]
|
||||
data_attrs[html_key] = {'value': value, '_delete': xml_key}
|
||||
|
||||
return data_attrs
|
||||
|
||||
def _render_annotation(self, element):
|
||||
""" Renders an annotation element for HTML output. """
|
||||
attr = {}
|
||||
attr.update(self._get_annotation_class_attr(element))
|
||||
attr.update(self._get_annotation_data_attr(element))
|
||||
|
||||
element.tag = 'span'
|
||||
|
||||
for key in attr.keys():
|
||||
element.set(key, attr[key]['value'])
|
||||
if '_delete' in attr[key] and attr[key]['_delete'] is not None:
|
||||
delete_key = attr[key]['_delete']
|
||||
del element.attrib[delete_key]
|
||||
|
||||
def _render_content(self):
|
||||
""" Renders annotatable content with annotation spans and returns HTML. """
|
||||
xmltree = etree.fromstring(self.content)
|
||||
xmltree.tag = 'div'
|
||||
if 'display_name' in xmltree.attrib:
|
||||
del xmltree.attrib['display_name']
|
||||
|
||||
for element in xmltree.findall('.//annotation'):
|
||||
self._render_annotation(element)
|
||||
|
||||
return etree.tostring(xmltree, encoding='unicode')
|
||||
self.user_email = ""
|
||||
if self.runtime.get_real_user is not None:
|
||||
self.user_email = self.runtime.get_real_user(self.runtime.anonymous_student_id).email
|
||||
|
||||
def _extract_instructions(self, xmltree):
|
||||
""" Removes <instructions> from the xmltree and returns them as a string, otherwise None. """
|
||||
@@ -155,9 +92,9 @@ class VideoAnnotationModule(AnnotatableFields, XModule):
|
||||
'sourceUrl': self.sourceurl,
|
||||
'typeSource': extension,
|
||||
'poster': self.poster_url,
|
||||
'alert': self,
|
||||
'content_html': self._render_content(),
|
||||
'annotation_storage': self.annotation_storage_url
|
||||
'content_html': self.content,
|
||||
'annotation_storage': self.annotation_storage_url,
|
||||
'token': retrieve_token(self.user_email, self.annotation_token_secret),
|
||||
}
|
||||
|
||||
return self.system.render_template('videoannotation.html', context)
|
||||
@@ -172,6 +109,7 @@ class VideoAnnotationDescriptor(AnnotatableFields, RawDescriptor):
|
||||
def non_editable_metadata_fields(self):
|
||||
non_editable_fields = super(VideoAnnotationDescriptor, self).non_editable_metadata_fields
|
||||
non_editable_fields.extend([
|
||||
VideoAnnotationDescriptor.annotation_storage_url
|
||||
VideoAnnotationDescriptor.annotation_storage_url,
|
||||
VideoAnnotationDescriptor.annotation_token_secret,
|
||||
])
|
||||
return non_editable_fields
|
||||
|
||||
@@ -763,6 +763,13 @@ class XModuleDescriptor(XModuleMixin, HTMLSnippet, ResourceTemplates, XBlock):
|
||||
json_choice = field.to_json(json_choice)
|
||||
return json_choice
|
||||
|
||||
def get_text(value):
|
||||
"""Localize a text value that might be None."""
|
||||
if value is None:
|
||||
return None
|
||||
else:
|
||||
return self.runtime.service(self, "i18n").ugettext(value)
|
||||
|
||||
metadata_fields = {}
|
||||
|
||||
# Only use the fields from this class, not mixins
|
||||
@@ -776,8 +783,8 @@ class XModuleDescriptor(XModuleMixin, HTMLSnippet, ResourceTemplates, XBlock):
|
||||
# gets the 'default_value' and 'explicitly_set' attrs
|
||||
metadata_fields[field.name] = self.runtime.get_field_provenance(self, field)
|
||||
metadata_fields[field.name]['field_name'] = field.name
|
||||
metadata_fields[field.name]['display_name'] = field.display_name
|
||||
metadata_fields[field.name]['help'] = field.help
|
||||
metadata_fields[field.name]['display_name'] = get_text(field.display_name)
|
||||
metadata_fields[field.name]['help'] = get_text(field.help)
|
||||
metadata_fields[field.name]['value'] = field.read_json(self)
|
||||
|
||||
# We support the following editors:
|
||||
|
||||
22
common/static/js/vendor/ova/annotator-full-firebase-auth.js
vendored
Normal file
22
common/static/js/vendor/ova/annotator-full-firebase-auth.js
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
Annotator.Plugin.Auth.prototype.haveValidToken = function() {
|
||||
return (
|
||||
this._unsafeToken &&
|
||||
this._unsafeToken.d.issuedAt &&
|
||||
this._unsafeToken.d.ttl &&
|
||||
this._unsafeToken.d.consumerKey &&
|
||||
this.timeToExpiry() > 0
|
||||
);
|
||||
};
|
||||
|
||||
Annotator.Plugin.Auth.prototype.timeToExpiry = function() {
|
||||
var expiry, issue, now, timeToExpiry;
|
||||
now = new Date().getTime() / 1000;
|
||||
issue = createDateFromISO8601(this._unsafeToken.d.issuedAt).getTime() / 1000;
|
||||
expiry = issue + this._unsafeToken.d.ttl;
|
||||
timeToExpiry = expiry - now;
|
||||
if (timeToExpiry > 0) {
|
||||
return timeToExpiry;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user