Merge pull request #3834 from cpennington/ok-merge-from-master
Merge master into opaque-keys
This commit is contained in:
1
AUTHORS
1
AUTHORS
@@ -146,3 +146,4 @@ Ben Weeks <benweeks@mit.edu>
|
||||
David Bodor <david.gabor.bodor@gmail.com>
|
||||
Sébastien Hinderer <Sebastien.Hinderer@inria.fr>
|
||||
Kristin Stephens <ksteph@cs.berkeley.edu>
|
||||
Ben Patterson <bpatterson@edx.org>
|
||||
|
||||
@@ -5,10 +5,18 @@ 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: Fix displaying transcripts on touch devices. BLD-1033.
|
||||
|
||||
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 +24,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()
|
||||
|
||||
|
||||
|
||||
@@ -10,10 +10,11 @@ Feature: Course export
|
||||
Then I get an error dialog
|
||||
And I can click to go to the unit with the error
|
||||
|
||||
Scenario: User is directed to problem with & in it when export fails
|
||||
Given I am in Studio editing a new unit
|
||||
When I add a "Blank Advanced Problem" "Advanced Problem" component
|
||||
And I edit and enter an ampersand
|
||||
And I export the course
|
||||
Then I get an error dialog
|
||||
And I can click to go to the unit with the error
|
||||
# Disabling due to failure on master. 05/21/2014 TODO: fix
|
||||
# Scenario: User is directed to problem with & in it when export fails
|
||||
# Given I am in Studio editing a new unit
|
||||
# When I add a "Blank Advanced Problem" "Advanced Problem" component
|
||||
# And I edit and enter an ampersand
|
||||
# And I export the course
|
||||
# Then I get an error dialog
|
||||
# And I can click to go to the unit with the error
|
||||
|
||||
@@ -105,26 +105,28 @@ Feature: CMS.HTML Editor
|
||||
<li>zzzz<ol>
|
||||
"""
|
||||
|
||||
Scenario: Can switch from Visual Editor to Raw
|
||||
Given I have created a Blank HTML Page
|
||||
When I edit the component and select the Raw Editor
|
||||
And I save the page
|
||||
When I edit the page
|
||||
And type "fancy html" into the Raw Editor
|
||||
And I save the page
|
||||
Then the page text contains:
|
||||
"""
|
||||
fancy html
|
||||
"""
|
||||
# Skipping in master due to brittleness JZ 05/22/2014
|
||||
# Scenario: Can switch from Visual Editor to Raw
|
||||
# Given I have created a Blank HTML Page
|
||||
# When I edit the component and select the Raw Editor
|
||||
# And I save the page
|
||||
# When I edit the page
|
||||
# And type "fancy html" into the Raw Editor
|
||||
# And I save the page
|
||||
# Then the page text contains:
|
||||
# """
|
||||
# fancy html
|
||||
# """
|
||||
|
||||
Scenario: Can switch from Raw Editor to Visual
|
||||
Given I have created a raw HTML component
|
||||
And I edit the component and select the Visual Editor
|
||||
And I save the page
|
||||
When I edit the page
|
||||
And type "less fancy html" in the code editor and press OK
|
||||
And I save the page
|
||||
Then the page text contains:
|
||||
"""
|
||||
less fancy html
|
||||
"""
|
||||
# Skipping in master due to brittleness JZ 05/22/2014
|
||||
# Scenario: Can switch from Raw Editor to Visual
|
||||
# Given I have created a raw HTML component
|
||||
# And I edit the component and select the Visual Editor
|
||||
# And I save the page
|
||||
# When I edit the page
|
||||
# And type "less fancy html" in the code editor and press OK
|
||||
# And I save the page
|
||||
# Then the page text contains:
|
||||
# """
|
||||
# less fancy html
|
||||
# """
|
||||
|
||||
@@ -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$')
|
||||
|
||||
@@ -6,9 +6,12 @@ Feature: CMS.Upload Files
|
||||
@skip_safari
|
||||
Scenario: Users can upload files
|
||||
Given I am at the files and upload page of a Studio course
|
||||
When I upload the file "test"
|
||||
When I upload the file "test" by clicking "Upload your first asset"
|
||||
Then I should see the file "test" was uploaded
|
||||
And The url for the file "test" is valid
|
||||
When I upload the file "test2"
|
||||
Then I should see the file "test2" was uploaded
|
||||
And The url for the file "test2" is valid
|
||||
|
||||
# Uploading isn't working on safari with sauce labs
|
||||
@skip_safari
|
||||
|
||||
@@ -25,8 +25,11 @@ def go_to_uploads(_step):
|
||||
|
||||
|
||||
@step(u'I upload the( test)? file "([^"]*)"$')
|
||||
def upload_file(_step, is_test_file, file_name):
|
||||
world.click_link('Upload New File')
|
||||
def upload_file(_step, is_test_file, file_name, button_text=None):
|
||||
if button_text:
|
||||
world.click_link(button_text)
|
||||
else:
|
||||
world.click_link('Upload New File')
|
||||
|
||||
if not is_test_file:
|
||||
_write_test_file(file_name, "test file")
|
||||
@@ -39,6 +42,11 @@ def upload_file(_step, is_test_file, file_name):
|
||||
world.css_click(close_css)
|
||||
|
||||
|
||||
@step(u'I upload the file "([^"]*)" by clicking "([^"]*)"')
|
||||
def upload_file_on_button_press(_step, file_name, button_text=None):
|
||||
upload_file(_step, '', file_name, button_text)
|
||||
|
||||
|
||||
@step(u'I upload the files "([^"]*)"$')
|
||||
def upload_files(_step, files_string):
|
||||
# files_string should be comma separated with no spaces.
|
||||
|
||||
@@ -16,6 +16,8 @@ from contentstore.utils import get_modulestore, reverse_course_url
|
||||
from .access import has_course_access
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
|
||||
from django.utils.translation import ugettext
|
||||
|
||||
__all__ = ['checklists_handler']
|
||||
|
||||
|
||||
@@ -76,7 +78,7 @@ def checklists_handler(request, course_key_string, checklist_index=None):
|
||||
course_module.save()
|
||||
get_modulestore(course_module.location).update_item(course_module, request.user.id)
|
||||
expanded_checklist = expand_checklist_action_url(course_module, persisted_checklist)
|
||||
return JsonResponse(expanded_checklist)
|
||||
return JsonResponse(localize_checklist_text(expanded_checklist))
|
||||
else:
|
||||
return HttpResponseBadRequest(
|
||||
("Could not save checklist state because the checklist index "
|
||||
@@ -96,7 +98,7 @@ def expand_all_action_urls(course_module):
|
||||
"""
|
||||
expanded_checklists = []
|
||||
for checklist in course_module.checklists:
|
||||
expanded_checklists.append(expand_checklist_action_url(course_module, checklist))
|
||||
expanded_checklists.append(localize_checklist_text(expand_checklist_action_url(course_module, checklist)))
|
||||
return expanded_checklists
|
||||
|
||||
|
||||
@@ -121,3 +123,20 @@ def expand_checklist_action_url(course_module, checklist):
|
||||
item['action_url'] = reverse_course_url(urlconf_map[action_url], course_module.id)
|
||||
|
||||
return expanded_checklist
|
||||
|
||||
def localize_checklist_text(checklist):
|
||||
"""
|
||||
Localize texts for a given checklist and returns the modified version.
|
||||
|
||||
The method does an in-place operation so the input checklist is modified directly.
|
||||
"""
|
||||
# Localize checklist name
|
||||
checklist['short_description'] = ugettext(checklist['short_description'])
|
||||
|
||||
# Localize checklist items
|
||||
for item in checklist.get('items'):
|
||||
item['short_description'] = ugettext(item['short_description'])
|
||||
item['long_description'] = ugettext(item['long_description'])
|
||||
item['action_text'] = ugettext(item['action_text']) if item['action_text'] != "" else u""
|
||||
|
||||
return checklist
|
||||
|
||||
@@ -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,9 @@ def unit_handler(request, usage_key_string):
|
||||
return render_to_response('unit.html', {
|
||||
'context_course': course,
|
||||
'unit': item,
|
||||
'unit_locator': usage_key,
|
||||
'xblocks': xblocks,
|
||||
'component_templates': component_templates,
|
||||
'unit_usage_key': usage_key,
|
||||
'child_usage_keys': [block.scope_ids.usage_id for block in xblocks],
|
||||
'component_templates': json.dumps(component_templates),
|
||||
'draft_preview_link': preview_lms_link,
|
||||
'published_preview_link': lms_link,
|
||||
'subsection': containing_subsection,
|
||||
@@ -293,14 +230,14 @@ def container_handler(request, usage_key_string):
|
||||
json: not currently supported
|
||||
"""
|
||||
if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'):
|
||||
|
||||
usage_key = UsageKey.from_string(usage_key_string)
|
||||
if not has_course_access(request.user, usage_key.course_key):
|
||||
raise PermissionDenied()
|
||||
try:
|
||||
xblock = get_modulestore(usage_key).get_item(usage_key)
|
||||
course, xblock, __ = _get_item_in_course(request, usage_key)
|
||||
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 +254,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':
|
||||
|
||||
@@ -21,7 +21,7 @@ from xblock.fragment import Fragment
|
||||
|
||||
import xmodule
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError, DuplicateItemError
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from xmodule.video_module import manage_video_subtitles_save
|
||||
|
||||
@@ -31,7 +31,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
|
||||
@@ -178,46 +178,56 @@ def xblock_view_handler(request, usage_key_string, view_name):
|
||||
|
||||
if 'application/json' in accept_header:
|
||||
store = get_modulestore(usage_key)
|
||||
component = store.get_item(usage_key)
|
||||
is_read_only = _xblock_is_read_only(component)
|
||||
xblock = store.get_item(usage_key)
|
||||
is_read_only = _is_xblock_read_only(xblock)
|
||||
container_views = ['container_preview', 'reorderable_container_child_preview']
|
||||
unit_views = ['student_view']
|
||||
|
||||
# wrap the generated fragment in the xmodule_editor div so that the javascript
|
||||
# can bind to it correctly
|
||||
component.runtime.wrappers.append(partial(wrap_xblock, 'StudioRuntime', usage_id_serializer=unicode))
|
||||
xblock.runtime.wrappers.append(partial(wrap_xblock, 'StudioRuntime', usage_id_serializer=unicode))
|
||||
|
||||
if view_name == 'studio_view':
|
||||
try:
|
||||
fragment = component.render('studio_view')
|
||||
fragment = xblock.render('studio_view')
|
||||
# catch exceptions indiscriminately, since after this point they escape the
|
||||
# dungeon and surface as uneditable, unsaveable, and undeletable
|
||||
# component-goblins.
|
||||
except Exception as exc: # pylint: disable=w0703
|
||||
log.debug("unable to render studio_view for %r", component, exc_info=True)
|
||||
log.debug("unable to render studio_view for %r", xblock, exc_info=True)
|
||||
fragment = Fragment(render_to_string('html_error.html', {'message': str(exc)}))
|
||||
|
||||
# change not authored by requestor but by xblocks.
|
||||
store.update_item(component, None)
|
||||
store.update_item(xblock, None)
|
||||
|
||||
elif view_name == 'student_view' and component.has_children:
|
||||
elif view_name == 'student_view' and xblock_has_own_studio_page(xblock):
|
||||
context = {
|
||||
'runtime_type': 'studio',
|
||||
'container_view': False,
|
||||
'read_only': is_read_only,
|
||||
'root_xblock': component,
|
||||
'root_xblock': xblock,
|
||||
}
|
||||
# For non-leaf xblocks on the unit page, show the special rendering
|
||||
# which links to the new container page.
|
||||
html = render_to_string('container_xblock_component.html', {
|
||||
'xblock_context': context,
|
||||
'xblock': component,
|
||||
'xblock': xblock,
|
||||
'locator': usage_key,
|
||||
})
|
||||
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 +236,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 +248,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 +266,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 +304,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 +412,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,7 @@ from util.sandboxing import can_execute_unsafe_code
|
||||
|
||||
import static_replace
|
||||
from .session_kv_store import SessionKeyValueStore
|
||||
from .helpers import render_from_lms
|
||||
from .helpers import render_from_lms, xblock_has_own_studio_page
|
||||
|
||||
from contentstore.views.access import get_user_role
|
||||
|
||||
@@ -156,6 +156,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 +170,19 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
|
||||
"""
|
||||
# Only add the Studio wrapper when on the container page. The unit page will remain as is for now.
|
||||
if context.get('container_view', None) and view == 'student_view':
|
||||
root_xblock = context.get('root_xblock')
|
||||
is_root = root_xblock and xblock.location == root_xblock.location
|
||||
is_reorderable = _is_xblock_reorderable(xblock, context)
|
||||
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)
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
"""
|
||||
Unit tests for the container view.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from contentstore.tests.utils import CourseTestCase
|
||||
from contentstore.utils import compute_publish_state, PublishState
|
||||
from contentstore.views.helpers import xblock_studio_url
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.factories import ItemFactory
|
||||
|
||||
|
||||
class ContainerViewTestCase(CourseTestCase):
|
||||
"""
|
||||
Unit tests for the container view.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(ContainerViewTestCase, self).setUp()
|
||||
self.chapter = ItemFactory.create(parent_location=self.course.location,
|
||||
category='chapter', display_name="Week 1")
|
||||
self.sequential = ItemFactory.create(parent_location=self.chapter.location,
|
||||
category='sequential', display_name="Lesson 1")
|
||||
self.vertical = ItemFactory.create(parent_location=self.sequential.location,
|
||||
category='vertical', display_name='Unit')
|
||||
self.child_vertical = ItemFactory.create(parent_location=self.vertical.location,
|
||||
category='vertical', display_name='Child Vertical')
|
||||
self.video = ItemFactory.create(parent_location=self.child_vertical.location,
|
||||
category="video", display_name="My Video")
|
||||
|
||||
def test_container_html(self):
|
||||
self._test_html_content(
|
||||
self.child_vertical,
|
||||
expected_location_in_section_tag=self.child_vertical.location,
|
||||
expected_breadcrumbs=(
|
||||
r'<a href="/unit/{unit_location}"\s*'
|
||||
r'class="navigation-link navigation-parent">Unit</a>\s*'
|
||||
r'<a href="#" class="navigation-link navigation-current">Child Vertical</a>'
|
||||
).format(unit_location=(unicode(self.vertical.location).replace("+", "\\+")))
|
||||
)
|
||||
|
||||
def test_container_on_container_html(self):
|
||||
"""
|
||||
Create the scenario of an xblock with children (non-vertical) on the container page.
|
||||
This should create a container page that is a child of another container page.
|
||||
"""
|
||||
published_xblock_with_child = ItemFactory.create(
|
||||
parent_location=self.child_vertical.location,
|
||||
category="wrapper", display_name="Wrapper"
|
||||
)
|
||||
ItemFactory.create(
|
||||
parent_location=published_xblock_with_child.location,
|
||||
category="html", display_name="Child HTML"
|
||||
)
|
||||
expected_breadcrumbs = (
|
||||
r'<a href="/unit/{unit_location}"\s*'
|
||||
r'class="navigation-link navigation-parent">Unit</a>\s*'
|
||||
r'<a href="/container/{child_vertical_location}"\s*'
|
||||
r'class="navigation-link navigation-parent">Child Vertical</a>\s*'
|
||||
r'<a href="#" class="navigation-link navigation-current">Wrapper</a>'
|
||||
).format(
|
||||
unit_location=unicode(self.vertical.location).replace("+", "\\+"),
|
||||
child_vertical_location=unicode(self.child_vertical.location).replace("+", "\\+"),
|
||||
)
|
||||
self._test_html_content(
|
||||
published_xblock_with_child,
|
||||
expected_location_in_section_tag=published_xblock_with_child.location,
|
||||
expected_breadcrumbs=expected_breadcrumbs
|
||||
)
|
||||
|
||||
# Now make the unit and its children into a draft and validate the container again
|
||||
modulestore('draft').convert_to_draft(self.vertical.location)
|
||||
modulestore('draft').convert_to_draft(self.child_vertical.location)
|
||||
draft_xblock_with_child = modulestore('draft').convert_to_draft(published_xblock_with_child.location)
|
||||
self._test_html_content(
|
||||
draft_xblock_with_child,
|
||||
expected_location_in_section_tag=draft_xblock_with_child.location,
|
||||
expected_breadcrumbs=expected_breadcrumbs
|
||||
)
|
||||
|
||||
def _test_html_content(self, xblock, expected_location_in_section_tag, expected_breadcrumbs):
|
||||
"""
|
||||
Get the HTML for a container page and verify the section tag is correct
|
||||
and the breadcrumbs trail is correct.
|
||||
"""
|
||||
publish_state = compute_publish_state(xblock)
|
||||
url = xblock_studio_url(xblock)
|
||||
resp = self.client.get_html(url)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
html = resp.content
|
||||
expected_section_tag = \
|
||||
'<section class="wrapper-xblock level-page is-hidden" ' \
|
||||
'data-locator="{child_location}" ' \
|
||||
'data-course-key="{course_key}">'.format(
|
||||
child_location=unicode(expected_location_in_section_tag),
|
||||
course_key=unicode(expected_location_in_section_tag.course_key)
|
||||
)
|
||||
|
||||
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.
|
||||
if publish_state == PublishState.public:
|
||||
expected_message = 'you need to edit unit <a href="/unit/{unit_location}">Unit</a> as a draft.'
|
||||
else:
|
||||
expected_message = 'your changes will be published with unit <a href="/unit/{unit_location}">Unit</a>.'
|
||||
expected_unit_link = expected_message.format(
|
||||
unit_location=unicode(self.vertical.location)
|
||||
)
|
||||
self.assertIn(expected_unit_link, html)
|
||||
|
||||
def test_container_preview_html(self):
|
||||
"""
|
||||
Verify that an xblock returns the expected HTML for a container preview
|
||||
"""
|
||||
# First verify that the behavior is correct with a published container
|
||||
self._test_preview_html(self.vertical)
|
||||
self._test_preview_html(self.child_vertical)
|
||||
|
||||
# Now make the unit and its children into a draft and validate the preview again
|
||||
draft_unit = modulestore('draft').convert_to_draft(self.vertical.location)
|
||||
draft_container = modulestore('draft').convert_to_draft(self.child_vertical.location)
|
||||
self._test_preview_html(draft_unit)
|
||||
self._test_preview_html(draft_container)
|
||||
|
||||
def _test_preview_html(self, xblock):
|
||||
"""
|
||||
Verify that the specified xblock has the expected HTML elements for container preview
|
||||
"""
|
||||
publish_state = compute_publish_state(xblock)
|
||||
preview_url = '/xblock/{}/container_preview'.format(xblock.location)
|
||||
|
||||
resp = self.client.get(preview_url, HTTP_ACCEPT='application/json')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
resp_content = json.loads(resp.content)
|
||||
html = resp_content['html']
|
||||
|
||||
# Verify that there are no drag handles for public pages
|
||||
drag_handle_html = '<span data-tooltip="Drag to reorder" class="drag-handle action"></span>'
|
||||
if publish_state == PublishState.public:
|
||||
self.assertNotIn(drag_handle_html, html)
|
||||
else:
|
||||
self.assertIn(drag_handle_html, html)
|
||||
156
cms/djangoapps/contentstore/views/tests/test_container_page.py
Normal file
156
cms/djangoapps/contentstore/views/tests/test_container_page.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
Unit tests for the container page.
|
||||
"""
|
||||
|
||||
import re
|
||||
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):
|
||||
self._test_html_content(
|
||||
self.child_container,
|
||||
expected_section_tag=(
|
||||
'<section class="wrapper-xblock level-page is-hidden studio-xblock-wrapper" '
|
||||
'data-locator="{0}" data-course-key="{0.course_key}">'.format(self.child_container.location)
|
||||
),
|
||||
expected_breadcrumbs=(
|
||||
r'<a href="/unit/{}"\s*'
|
||||
r'class="navigation-link navigation-parent">Unit</a>\s*'
|
||||
r'<a href="#" class="navigation-link navigation-current">Split Test</a>'
|
||||
).format(re.escape(unicode(self.vertical.location)))
|
||||
)
|
||||
|
||||
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):
|
||||
self._test_html_content(
|
||||
xblock,
|
||||
expected_section_tag=(
|
||||
'<section class="wrapper-xblock level-page is-hidden studio-xblock-wrapper" '
|
||||
'data-locator="{0}" data-course-key="{0.course_key}">'.format(published_container.location)
|
||||
),
|
||||
expected_breadcrumbs=(
|
||||
r'<a href="/unit/{unit}"\s*'
|
||||
r'class="navigation-link navigation-parent">Unit</a>\s*'
|
||||
r'<a href="/container/{split_test}"\s*'
|
||||
r'class="navigation-link navigation-parent">Split Test</a>\s*'
|
||||
r'<a href="#" class="navigation-link navigation-current">Wrapper</a>'
|
||||
).format(
|
||||
unit=re.escape(unicode(self.vertical.location)),
|
||||
split_test=re.escape(unicode(self.child_container.location))
|
||||
)
|
||||
)
|
||||
|
||||
# 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, 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/{}">Unit</a> as a draft.'
|
||||
else:
|
||||
expected_message = 'your changes will be published with unit <a href="/unit/{}">Unit</a>.'
|
||||
expected_unit_link = expected_message.format(self.vertical.location)
|
||||
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_usage_key, 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_usage_key, False))
|
||||
draft_1 = self.get_item_from_modulestore(self.problem_usage_key, 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_usage_key, 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_usage_key, 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_usage_key, False)
|
||||
draft_1 = self.get_item_from_modulestore(self.problem_usage_key, 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_usage_key, False)
|
||||
draft_2 = self.get_item_from_modulestore(self.problem_usage_key, 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)
|
||||
79
cms/djangoapps/contentstore/views/tests/utils.py
Normal file
79
cms/djangoapps/contentstore/views/tests/utils.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
Utilities for view tests.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from contentstore.tests.utils import CourseTestCase
|
||||
from contentstore.views.helpers import xblock_studio_url
|
||||
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.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.
|
||||
"""
|
||||
preview_url = '/xblock/{usage_key}/{view_name}'.format(usage_key=xblock.location, 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',
|
||||
},
|
||||
|
||||
@@ -220,6 +220,7 @@ define([
|
||||
|
||||
"js/spec/views/baseview_spec",
|
||||
"js/spec/views/paging_spec",
|
||||
"js/spec/views/assets_spec",
|
||||
|
||||
"js/spec/views/container_spec",
|
||||
"js/spec/views/unit_spec",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
define ["jasmine", "js/spec_helpers/create_sinon", "squire"],
|
||||
(jasmine, create_sinon, Squire) ->
|
||||
define ["jquery", "jasmine", "js/spec_helpers/create_sinon", "squire"],
|
||||
($, jasmine, create_sinon, Squire) ->
|
||||
|
||||
feedbackTpl = readFixtures('system-feedback.underscore')
|
||||
assetLibraryTpl = readFixtures('asset-library.underscore')
|
||||
@@ -236,6 +236,33 @@ define ["jasmine", "js/spec_helpers/create_sinon", "squire"],
|
||||
create_sinon.respondWithJson(requests, @mockAssetsResponse)
|
||||
return requests
|
||||
|
||||
$.fn.fileupload = ->
|
||||
return ''
|
||||
|
||||
clickEvent = (html_selector) ->
|
||||
$(html_selector).click()
|
||||
|
||||
it "should show upload modal on clicking upload asset button", ->
|
||||
spyOn(@view, "showUploadModal")
|
||||
setup.call(this)
|
||||
expect(@view.showUploadModal).not.toHaveBeenCalled()
|
||||
@view.showUploadModal(clickEvent(".upload-button"))
|
||||
expect(@view.showUploadModal).toHaveBeenCalled()
|
||||
|
||||
it "should show file selection menu on choose file button", ->
|
||||
spyOn(@view, "showFileSelectionMenu")
|
||||
setup.call(this)
|
||||
expect(@view.showFileSelectionMenu).not.toHaveBeenCalled()
|
||||
@view.showFileSelectionMenu(clickEvent(".choose-file-button"))
|
||||
expect(@view.showFileSelectionMenu).toHaveBeenCalled()
|
||||
|
||||
it "should hide upload modal on clicking close button", ->
|
||||
spyOn(@view, "hideModal")
|
||||
setup.call(this)
|
||||
expect(@view.hideModal).not.toHaveBeenCalled()
|
||||
@view.hideModal(clickEvent(".close-button"))
|
||||
expect(@view.hideModal).toHaveBeenCalled()
|
||||
|
||||
it "should show a status indicator while loading", ->
|
||||
appendSetFixtures('<div class="ui-loading"/>')
|
||||
expect($('.ui-loading').is(':visible')).toBe(true)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
129
cms/static/js/spec/views/assets_spec.js
Normal file
129
cms/static/js/spec/views/assets_spec.js
Normal file
@@ -0,0 +1,129 @@
|
||||
define([ "jquery", "js/spec_helpers/create_sinon", "js/views/asset", "js/views/assets",
|
||||
"js/models/asset", "js/collections/asset" ],
|
||||
function ($, create_sinon, AssetView, AssetsView, AssetModel, AssetCollection) {
|
||||
|
||||
describe("Assets", function() {
|
||||
var assetsView, mockEmptyAssetsResponse, mockAssetUploadResponse,
|
||||
assetLibraryTpl, assetTpl, pagingFooterTpl, pagingHeaderTpl, uploadModalTpl;
|
||||
|
||||
assetLibraryTpl = readFixtures('asset-library.underscore');
|
||||
assetTpl = readFixtures('asset.underscore');
|
||||
pagingHeaderTpl = readFixtures('paging-header.underscore');
|
||||
pagingFooterTpl = readFixtures('paging-footer.underscore');
|
||||
uploadModalTpl = readFixtures('asset-upload-modal.underscore');
|
||||
|
||||
beforeEach(function () {
|
||||
setFixtures($("<script>", { id: "asset-library-tpl", type: "text/template" }).text(assetLibraryTpl));
|
||||
appendSetFixtures($("<script>", { id: "asset-tpl", type: "text/template" }).text(assetTpl));
|
||||
appendSetFixtures($("<script>", { id: "paging-header-tpl", type: "text/template" }).text(pagingHeaderTpl));
|
||||
appendSetFixtures($("<script>", { id: "paging-footer-tpl", type: "text/template" }).text(pagingFooterTpl));
|
||||
appendSetFixtures(uploadModalTpl);
|
||||
appendSetFixtures(sandbox({ id: "asset_table_body" }));
|
||||
|
||||
var collection = new AssetCollection();
|
||||
collection.url = "assets-url";
|
||||
assetsView = new AssetsView({
|
||||
collection: collection,
|
||||
el: $('#asset_table_body')
|
||||
});
|
||||
assetsView.render();
|
||||
});
|
||||
|
||||
var mockAsset = {
|
||||
display_name: "dummy.jpg",
|
||||
url: 'actual_asset_url',
|
||||
portable_url: 'portable_url',
|
||||
date_added: 'date',
|
||||
thumbnail: null,
|
||||
locked: false,
|
||||
id: 'id_1'
|
||||
};
|
||||
|
||||
mockEmptyAssetsResponse = {
|
||||
assets: [],
|
||||
start: 0,
|
||||
end: 0,
|
||||
page: 0,
|
||||
pageSize: 5,
|
||||
totalCount: 0
|
||||
};
|
||||
|
||||
mockAssetUploadResponse = {
|
||||
asset: mockAsset,
|
||||
msg: "Upload completed"
|
||||
};
|
||||
|
||||
$.fn.fileupload = function() {
|
||||
return '';
|
||||
};
|
||||
|
||||
var event = {}
|
||||
event.target = {"value": "dummy.jpg"};
|
||||
|
||||
describe("AssetsView", function () {
|
||||
var setup;
|
||||
setup = function() {
|
||||
var requests;
|
||||
requests = create_sinon.requests(this);
|
||||
assetsView.setPage(0);
|
||||
create_sinon.respondWithJson(requests, mockEmptyAssetsResponse);
|
||||
return requests;
|
||||
};
|
||||
|
||||
beforeEach(function () {
|
||||
window.analytics = jasmine.createSpyObj('analytics', ['track']);
|
||||
window.course_location_analytics = jasmine.createSpy();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
delete window.analytics;
|
||||
delete window.course_location_analytics;
|
||||
});
|
||||
|
||||
it('shows the upload modal when clicked on "Upload your first asset" button', function () {
|
||||
expect(assetsView).toBeDefined();
|
||||
appendSetFixtures('<div class="ui-loading"/>');
|
||||
expect($('.ui-loading').is(':visible')).toBe(true);
|
||||
expect($('.upload-button').is(':visible')).toBe(false);
|
||||
setup.call(this);
|
||||
expect($('.ui-loading').is(':visible')).toBe(false);
|
||||
expect($('.upload-button').is(':visible')).toBe(true);
|
||||
|
||||
expect($('.upload-modal').is(':visible')).toBe(false);
|
||||
$('a:contains("Upload your first asset")').click();
|
||||
expect($('.upload-modal').is(':visible')).toBe(true);
|
||||
|
||||
$('.close-button').click();
|
||||
expect($('.upload-modal').is(':visible')).toBe(false);
|
||||
});
|
||||
|
||||
it('uploads file properly', function () {
|
||||
var requests = setup.call(this);
|
||||
expect(assetsView).toBeDefined();
|
||||
spyOn(assetsView, "addAsset").andCallFake(function () {
|
||||
assetsView.collection.add(mockAssetUploadResponse.asset);
|
||||
assetsView.renderPageItems();
|
||||
assetsView.setPage(0);
|
||||
});
|
||||
|
||||
$('a:contains("Upload your first asset")').click();
|
||||
expect($('.upload-modal').is(':visible')).toBe(true);
|
||||
|
||||
$('.choose-file-button').click();
|
||||
$("input[type=file]").change();
|
||||
expect($('.upload-modal h1').text()).toContain("Uploading");
|
||||
|
||||
assetsView.showUploadFeedback(event, 100);
|
||||
expect($('div.progress-bar').text()).toContain("100%");
|
||||
|
||||
assetsView.displayFinishedUpload(mockAssetUploadResponse);
|
||||
expect($('div.progress-bar').text()).toContain("Upload completed");
|
||||
$('.close-button').click();
|
||||
expect($('.upload-modal').is(':visible')).toBe(false);
|
||||
|
||||
expect($('#asset_table_body').html()).toContain("dummy.jpg");
|
||||
expect(assetsView.collection.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,35 +205,32 @@ 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);
|
||||
create_sinon.respondWithJson(requests, {});
|
||||
|
||||
// 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, {}, "");
|
||||
|
||||
// expect request URL to contain given component's id
|
||||
expect(lastRequest().url).toMatch(
|
||||
// first request contains given component's id (to delete the component)
|
||||
expect(requests[requests.length - 2].url).toMatch(
|
||||
new RegExp("locator-component-" + GROUP_TO_TEST + (componentIndex + 1))
|
||||
);
|
||||
|
||||
// second request contains parent's id (to remove as child)
|
||||
expect(lastRequest().url).toMatch(
|
||||
new RegExp("locator-group-" + GROUP_TO_TEST)
|
||||
);
|
||||
};
|
||||
|
||||
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 +239,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 +270,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,17 +314,9 @@ 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.url).toEqual("/xblock/");
|
||||
expect(request.method).toEqual("POST");
|
||||
expect(JSON.parse(request.requestBody)).toEqual(
|
||||
JSON.parse(
|
||||
@@ -349,6 +326,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 +340,117 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers"
|
||||
// duplicate component with an 'OK' response code
|
||||
duplicateComponentWithResponse(componentIndex, 200);
|
||||
|
||||
// expect 'duplicating' notification to be hidden
|
||||
expect(notificationSpies.hide).toHaveBeenCalled();
|
||||
|
||||
// expect parent container to be refreshed
|
||||
expect(refreshXBlockSpies).toHaveBeenCalled();
|
||||
};
|
||||
|
||||
it("duplicates first xblock", function() {
|
||||
renderContainerPage(ABTestFixture, this);
|
||||
it("can duplicate the first xblock", function() {
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
duplicateComponentWithSuccess(0);
|
||||
});
|
||||
|
||||
it("duplicates middle xblock", function() {
|
||||
renderContainerPage(ABTestFixture, this);
|
||||
it("can duplicate a middle xblock", function() {
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
duplicateComponentWithSuccess(1);
|
||||
});
|
||||
|
||||
it("duplicates last xblock", function() {
|
||||
renderContainerPage(ABTestFixture, this);
|
||||
it("can duplicate the last xblock", function() {
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
duplicateComponentWithSuccess(NUM_COMPONENTS_PER_GROUP - 1);
|
||||
});
|
||||
|
||||
it('does not duplicate xblock upon failure', function () {
|
||||
renderContainerPage(ABTestFixture, this);
|
||||
duplicateComponentWithResponse(0, 500);
|
||||
it('shows a notification when duplicating', function () {
|
||||
var notificationSpy = edit_helpers.createNotificationSpy();
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
clickDuplicate(0);
|
||||
edit_helpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
|
||||
create_sinon.respondWithJson(requests, {"locator": "new_item"});
|
||||
edit_helpers.verifyNotificationHidden(notificationSpy);
|
||||
});
|
||||
|
||||
it('does not duplicate an xblock upon failure', function () {
|
||||
var notificationSpy = edit_helpers.createNotificationSpy();
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
clickDuplicate(0);
|
||||
edit_helpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
|
||||
create_sinon.respondWithError(requests);
|
||||
expectComponents(getGroupElement(), allComponentsInGroup);
|
||||
expect(notificationSpies.hide).not.toHaveBeenCalled();
|
||||
expect(refreshXBlockSpies).not.toHaveBeenCalled();
|
||||
edit_helpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNewComponent ', function () {
|
||||
var clickNewComponent, verifyComponents;
|
||||
|
||||
clickNewComponent = function (index) {
|
||||
containerPage.$(".new-component .new-component-type a.single-template")[index].click();
|
||||
};
|
||||
|
||||
it('sends the correct JSON to the server', function () {
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
clickNewComponent(0);
|
||||
edit_helpers.verifyXBlockRequest(requests, {
|
||||
"category": "discussion",
|
||||
"type": "discussion",
|
||||
"parent_locator": "locator-group-A"
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a notification while creating', function () {
|
||||
var notificationSpy = edit_helpers.createNotificationSpy();
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
clickNewComponent(0);
|
||||
edit_helpers.verifyNotificationShowing(notificationSpy, /Adding/);
|
||||
create_sinon.respondWithJson(requests, { });
|
||||
edit_helpers.verifyNotificationHidden(notificationSpy);
|
||||
});
|
||||
|
||||
it('does not insert component upon failure', function () {
|
||||
var requestCount;
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
clickNewComponent(0);
|
||||
requestCount = requests.length;
|
||||
create_sinon.respondWithError(requests);
|
||||
// No new requests should be made to refresh the view
|
||||
expect(requests.length).toBe(requestCount);
|
||||
expectComponents(getGroupElement(), allComponentsInGroup);
|
||||
});
|
||||
|
||||
describe('Template Picker', function() {
|
||||
var showTemplatePicker, verifyCreateHtmlComponent,
|
||||
mockXBlockHtml = readFixtures('mock/mock-xblock.underscore');
|
||||
|
||||
showTemplatePicker = function() {
|
||||
containerPage.$('.new-component .new-component-type a.multiple-templates')[0].click();
|
||||
};
|
||||
|
||||
verifyCreateHtmlComponent = function(test, templateIndex, expectedRequest) {
|
||||
var xblockCount;
|
||||
renderContainerPage(mockContainerXBlockHtml, test);
|
||||
showTemplatePicker();
|
||||
xblockCount = containerPage.$('.studio-xblock-wrapper').length;
|
||||
containerPage.$('.new-component-html a')[templateIndex].click();
|
||||
edit_helpers.verifyXBlockRequest(requests, expectedRequest);
|
||||
create_sinon.respondWithJson(requests, {"locator": "new_item"});
|
||||
respondWithHtml(mockXBlockHtml);
|
||||
expect(containerPage.$('.studio-xblock-wrapper').length).toBe(xblockCount + 1);
|
||||
};
|
||||
|
||||
it('can add an HTML component without a template', function() {
|
||||
verifyCreateHtmlComponent(this, 0, {
|
||||
"category": "html",
|
||||
"parent_locator": "locator-group-A"
|
||||
});
|
||||
});
|
||||
|
||||
it('can add an HTML component with a template', function() {
|
||||
verifyCreateHtmlComponent(this, 1, {
|
||||
"category": "html",
|
||||
"boilerplate" : "announcement.yaml",
|
||||
"parent_locator": "locator-group-A"
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,240 +1,178 @@
|
||||
define(["coffee/src/views/unit", "js/models/module_info", "js/spec_helpers/create_sinon", "js/views/feedback_notification",
|
||||
"jasmine-stealth"],
|
||||
function (UnitEditView, ModuleModel, create_sinon, NotificationView) {
|
||||
var verifyJSON = function (requests, json) {
|
||||
var request = requests[requests.length - 1];
|
||||
expect(request.url).toEqual("/xblock/");
|
||||
expect(request.method).toEqual("POST");
|
||||
// There was a problem with order of returned parameters in strings.
|
||||
// Changed to compare objects instead strings.
|
||||
expect(JSON.parse(request.requestBody)).toEqual(JSON.parse(json));
|
||||
define(["jquery", "underscore", "jasmine", "coffee/src/views/unit", "js/models/module_info",
|
||||
"js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers", "jasmine-stealth"],
|
||||
function ($, _, jasmine, UnitEditView, ModuleModel, create_sinon, edit_helpers) {
|
||||
var requests, unitView, initialize, respondWithHtml, verifyComponents, i;
|
||||
|
||||
respondWithHtml = function(html, requestIndex) {
|
||||
create_sinon.respondWithJson(
|
||||
requests,
|
||||
{ html: html, "resources": [] },
|
||||
requestIndex
|
||||
);
|
||||
};
|
||||
|
||||
var verifyComponents = function (unit, locators) {
|
||||
initialize = function(test) {
|
||||
var mockXBlockHtml = readFixtures('mock/mock-unit-page-xblock.underscore'),
|
||||
model;
|
||||
requests = create_sinon.requests(test);
|
||||
model = new ModuleModel({
|
||||
id: 'unit_locator',
|
||||
state: 'draft'
|
||||
});
|
||||
unitView = new UnitEditView({
|
||||
el: $('.main-wrapper'),
|
||||
templates: edit_helpers.mockComponentTemplates,
|
||||
model: model
|
||||
});
|
||||
|
||||
// Respond with renderings for the two xblocks in the unit
|
||||
respondWithHtml(mockXBlockHtml, 0);
|
||||
respondWithHtml(mockXBlockHtml, 1);
|
||||
};
|
||||
|
||||
verifyComponents = function (unit, locators) {
|
||||
var components = unit.$(".component");
|
||||
expect(components.length).toBe(locators.length);
|
||||
for (var i=0; i < locators.length; i++) {
|
||||
for (i = 0; i < locators.length; i++) {
|
||||
expect($(components[i]).data('locator')).toBe(locators[i]);
|
||||
}
|
||||
};
|
||||
|
||||
var verifyNotification = function (notificationSpy, text, requests) {
|
||||
expect(notificationSpy.constructor).toHaveBeenCalled();
|
||||
expect(notificationSpy.show).toHaveBeenCalled();
|
||||
expect(notificationSpy.hide).not.toHaveBeenCalled();
|
||||
var options = notificationSpy.constructor.mostRecentCall.args[0];
|
||||
expect(options.title).toMatch(text);
|
||||
create_sinon.respondWithJson(requests, {"locator": "new_item"});
|
||||
expect(notificationSpy.hide).toHaveBeenCalled();
|
||||
};
|
||||
beforeEach(function() {
|
||||
edit_helpers.installMockXBlock();
|
||||
|
||||
describe('duplicateComponent ', function () {
|
||||
var duplicateFixture =
|
||||
'<div class="main-wrapper edit-state-draft" data-locator="unit_locator"> \
|
||||
<ol class="components"> \
|
||||
<li class="component" data-locator="loc_1"> \
|
||||
<div class="wrapper wrapper-component-editor"/> \
|
||||
<ul class="component-actions"> \
|
||||
<a href="#" data-tooltip="Duplicate" class="duplicate-button action-button"><i class="icon-copy"></i><span class="sr"></span>Duplicate</span></a> \
|
||||
</ul> \
|
||||
</li> \
|
||||
<li class="component" data-locator="loc_2"> \
|
||||
<div class="wrapper wrapper-component-editor"/> \
|
||||
<ul class="component-actions"> \
|
||||
<a href="#" data-tooltip="Duplicate" class="duplicate-button action-button"><i class="icon-copy"></i><span class="sr"></span>Duplicate</span></a> \
|
||||
</ul> \
|
||||
</li> \
|
||||
</ol> \
|
||||
</div>';
|
||||
|
||||
var unit;
|
||||
var clickDuplicate = function (index) {
|
||||
unit.$(".duplicate-button")[index].click();
|
||||
};
|
||||
beforeEach(function () {
|
||||
setFixtures(duplicateFixture);
|
||||
unit = new UnitEditView({
|
||||
el: $('.main-wrapper'),
|
||||
model: new ModuleModel({
|
||||
id: 'unit_locator',
|
||||
state: 'draft'
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
it('sends the correct JSON to the server', function () {
|
||||
var requests = create_sinon.requests(this);
|
||||
clickDuplicate(0);
|
||||
verifyJSON(requests, '{"duplicate_source_locator":"loc_1","parent_locator":"unit_locator"}');
|
||||
});
|
||||
|
||||
it('inserts duplicated component immediately after source upon success', function () {
|
||||
var requests = create_sinon.requests(this);
|
||||
clickDuplicate(0);
|
||||
create_sinon.respondWithJson(requests, {"locator": "duplicated_item"});
|
||||
verifyComponents(unit, ['loc_1', 'duplicated_item', 'loc_2']);
|
||||
});
|
||||
|
||||
it('inserts duplicated component at end if source at end', function () {
|
||||
var requests = create_sinon.requests(this);
|
||||
clickDuplicate(1);
|
||||
create_sinon.respondWithJson(requests, {"locator": "duplicated_item"});
|
||||
verifyComponents(unit, ['loc_1', 'loc_2', 'duplicated_item']);
|
||||
});
|
||||
|
||||
it('shows a notification while duplicating', function () {
|
||||
var notificationSpy = spyOnConstructor(NotificationView, "Mini", ["show", "hide"]);
|
||||
notificationSpy.show.andReturn(notificationSpy);
|
||||
|
||||
var requests = create_sinon.requests(this);
|
||||
clickDuplicate(0);
|
||||
verifyNotification(notificationSpy, /Duplicating/, requests);
|
||||
});
|
||||
|
||||
it('does not insert duplicated component upon failure', function () {
|
||||
var server = create_sinon.server(500, this);
|
||||
clickDuplicate(0);
|
||||
server.respond();
|
||||
verifyComponents(unit, ['loc_1', 'loc_2']);
|
||||
});
|
||||
// needed to stub out the ajax
|
||||
window.analytics = jasmine.createSpyObj('analytics', ['track']);
|
||||
window.course_location_analytics = jasmine.createSpy('course_location_analytics');
|
||||
window.unit_location_analytics = jasmine.createSpy('unit_location_analytics');
|
||||
});
|
||||
describe('saveNewComponent ', function () {
|
||||
var newComponentFixture =
|
||||
'<div class="main-wrapper edit-state-draft" data-locator="unit_locator"> \
|
||||
<ol class="components"> \
|
||||
<li class="component" data-locator="loc_1"> \
|
||||
<div class="wrapper wrapper-component-editor"/> \
|
||||
</li> \
|
||||
<li class="component" data-locator="loc_2"> \
|
||||
<div class="wrapper wrapper-component-editor"/> \
|
||||
</li> \
|
||||
<li class="new-component-item adding"> \
|
||||
<div class="new-component"> \
|
||||
<ul class="new-component-type"> \
|
||||
<li> \
|
||||
<a href="#" class="single-template" data-type="discussion" data-category="discussion"/> \
|
||||
</li> \
|
||||
</ul> \
|
||||
</div> \
|
||||
</li> \
|
||||
</ol> \
|
||||
</div>';
|
||||
|
||||
var unit;
|
||||
var clickNewComponent = function () {
|
||||
unit.$(".new-component .new-component-type a.single-template").click();
|
||||
};
|
||||
beforeEach(function () {
|
||||
setFixtures(newComponentFixture);
|
||||
unit = new UnitEditView({
|
||||
el: $('.main-wrapper'),
|
||||
model: new ModuleModel({
|
||||
id: 'unit_locator',
|
||||
state: 'draft'
|
||||
})
|
||||
});
|
||||
});
|
||||
it('sends the correct JSON to the server', function () {
|
||||
var requests = create_sinon.requests(this);
|
||||
clickNewComponent();
|
||||
verifyJSON(requests, '{"category":"discussion","type":"discussion","parent_locator":"unit_locator"}');
|
||||
});
|
||||
|
||||
it('inserts new component at end', function () {
|
||||
var requests = create_sinon.requests(this);
|
||||
clickNewComponent();
|
||||
create_sinon.respondWithJson(requests, {"locator": "new_item"});
|
||||
verifyComponents(unit, ['loc_1', 'loc_2', 'new_item']);
|
||||
});
|
||||
|
||||
it('shows a notification while creating', function () {
|
||||
var notificationSpy = spyOnConstructor(NotificationView, "Mini", ["show", "hide"]);
|
||||
notificationSpy.show.andReturn(notificationSpy);
|
||||
var requests = create_sinon.requests(this);
|
||||
clickNewComponent();
|
||||
verifyNotification(notificationSpy, /Adding/, requests);
|
||||
});
|
||||
|
||||
it('does not insert duplicated component upon failure', function () {
|
||||
var server = create_sinon.server(500, this);
|
||||
clickNewComponent();
|
||||
server.respond();
|
||||
verifyComponents(unit, ['loc_1', 'loc_2']);
|
||||
});
|
||||
afterEach(function () {
|
||||
edit_helpers.uninstallMockXBlock();
|
||||
});
|
||||
describe("Disabled edit/publish links during ajax call", function() {
|
||||
var unit,
|
||||
link,
|
||||
draft_states = [
|
||||
{
|
||||
state: "draft",
|
||||
selector: ".publish-draft"
|
||||
},
|
||||
{
|
||||
state: "public",
|
||||
selector: ".create-draft"
|
||||
}
|
||||
],
|
||||
editLinkFixture =
|
||||
'<div class="main-wrapper edit-state-draft" data-locator="unit_locator"> \
|
||||
<div class="unit-settings window"> \
|
||||
<h4 class="header">Unit Settings</h4> \
|
||||
<div class="window-contents"> \
|
||||
<div class="row published-alert"> \
|
||||
<p class="edit-draft-message"> \
|
||||
<a href="#" class="create-draft">edit a draft</a> \
|
||||
</p> \
|
||||
<p class="publish-draft-message"> \
|
||||
<a href="#" class="publish-draft">replace it with this draft</a> \
|
||||
</p> \
|
||||
</div> \
|
||||
</div> \
|
||||
</div> \
|
||||
</div>';
|
||||
function test_link_disabled_during_ajax_call(draft_state) {
|
||||
beforeEach(function () {
|
||||
setFixtures(editLinkFixture);
|
||||
unit = new UnitEditView({
|
||||
el: $('.main-wrapper'),
|
||||
model: new ModuleModel({
|
||||
id: 'unit_locator',
|
||||
state: draft_state['state']
|
||||
})
|
||||
|
||||
describe("UnitEditView", function() {
|
||||
beforeEach(function() {
|
||||
edit_helpers.installEditTemplates();
|
||||
appendSetFixtures(readFixtures('mock/mock-unit-page.underscore'));
|
||||
});
|
||||
|
||||
describe('duplicateComponent', function() {
|
||||
var clickDuplicate;
|
||||
|
||||
clickDuplicate = function (index) {
|
||||
unitView.$(".duplicate-button")[index].click();
|
||||
};
|
||||
|
||||
it('sends the correct JSON to the server', function () {
|
||||
initialize(this);
|
||||
clickDuplicate(0);
|
||||
edit_helpers.verifyXBlockRequest(requests, {
|
||||
"duplicate_source_locator": "loc_1",
|
||||
"parent_locator": "unit_locator"
|
||||
});
|
||||
// needed to stub out the ajax
|
||||
window.analytics = jasmine.createSpyObj('analytics', ['track']);
|
||||
window.course_location_analytics = jasmine.createSpy('course_location_analytics');
|
||||
window.unit_location_analytics = jasmine.createSpy('unit_location_analytics');
|
||||
});
|
||||
|
||||
it("reenables the " + draft_state['selector'] + " link once the ajax call returns", function() {
|
||||
runs(function(){
|
||||
spyOn($, "ajax").andCallThrough();
|
||||
spyOn($.fn, 'addClass').andCallThrough();
|
||||
spyOn($.fn, 'removeClass').andCallThrough();
|
||||
link = $(draft_state['selector']);
|
||||
it('inserts duplicated component immediately after source upon success', function () {
|
||||
initialize(this);
|
||||
clickDuplicate(0);
|
||||
create_sinon.respondWithJson(requests, {"locator": "duplicated_item"});
|
||||
verifyComponents(unitView, ['loc_1', 'duplicated_item', 'loc_2']);
|
||||
});
|
||||
|
||||
it('inserts duplicated component at end if source at end', function () {
|
||||
initialize(this);
|
||||
clickDuplicate(1);
|
||||
create_sinon.respondWithJson(requests, {"locator": "duplicated_item"});
|
||||
verifyComponents(unitView, ['loc_1', 'loc_2', 'duplicated_item']);
|
||||
});
|
||||
|
||||
it('shows a notification while duplicating', function () {
|
||||
var notificationSpy = edit_helpers.createNotificationSpy();
|
||||
initialize(this);
|
||||
clickDuplicate(0);
|
||||
edit_helpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
|
||||
create_sinon.respondWithJson(requests, {"locator": "new_item"});
|
||||
edit_helpers.verifyNotificationHidden(notificationSpy);
|
||||
});
|
||||
|
||||
it('does not insert duplicated component upon failure', function () {
|
||||
initialize(this);
|
||||
clickDuplicate(0);
|
||||
create_sinon.respondWithError(requests);
|
||||
verifyComponents(unitView, ['loc_1', 'loc_2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNewComponent ', function () {
|
||||
var clickNewComponent;
|
||||
|
||||
clickNewComponent = function () {
|
||||
unitView.$(".new-component .new-component-type a.single-template").click();
|
||||
};
|
||||
|
||||
it('sends the correct JSON to the server', function () {
|
||||
initialize(this);
|
||||
clickNewComponent();
|
||||
edit_helpers.verifyXBlockRequest(requests, {
|
||||
"category": "discussion",
|
||||
"type": "discussion",
|
||||
"parent_locator": "unit_locator"
|
||||
});
|
||||
});
|
||||
|
||||
it('inserts new component at end', function () {
|
||||
initialize(this);
|
||||
clickNewComponent();
|
||||
create_sinon.respondWithJson(requests, {"locator": "new_item"});
|
||||
verifyComponents(unitView, ['loc_1', 'loc_2', 'new_item']);
|
||||
});
|
||||
|
||||
it('shows a notification while creating', function () {
|
||||
var notificationSpy = edit_helpers.createNotificationSpy();
|
||||
initialize(this);
|
||||
clickNewComponent();
|
||||
edit_helpers.verifyNotificationShowing(notificationSpy, /Adding/);
|
||||
create_sinon.respondWithJson(requests, {"locator": "new_item"});
|
||||
edit_helpers.verifyNotificationHidden(notificationSpy);
|
||||
});
|
||||
|
||||
it('does not insert new component upon failure', function () {
|
||||
initialize(this);
|
||||
clickNewComponent();
|
||||
create_sinon.respondWithError(requests);
|
||||
verifyComponents(unitView, ['loc_1', 'loc_2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Disabled edit/publish links during ajax call", function() {
|
||||
var link, i,
|
||||
draft_states = [
|
||||
{
|
||||
state: "draft",
|
||||
selector: ".publish-draft"
|
||||
},
|
||||
{
|
||||
state: "public",
|
||||
selector: ".create-draft"
|
||||
}
|
||||
];
|
||||
|
||||
function test_link_disabled_during_ajax_call(draft_state) {
|
||||
it("re-enables the " + draft_state.selector + " link once the ajax call returns", function() {
|
||||
initialize(this);
|
||||
link = $(draft_state.selector);
|
||||
expect(link).not.toHaveClass('is-disabled');
|
||||
link.click();
|
||||
expect(link).toHaveClass('is-disabled');
|
||||
create_sinon.respondWithError(requests);
|
||||
expect(link).not.toHaveClass('is-disabled');
|
||||
});
|
||||
waitsFor(function(){
|
||||
// wait for "is-disabled" to be removed as a class
|
||||
return !($(draft_state['selector']).hasClass("is-disabled"));
|
||||
}, 500);
|
||||
runs(function(){
|
||||
// check that the `is-disabled` class was added and removed
|
||||
expect($.fn.addClass).toHaveBeenCalledWith("is-disabled");
|
||||
expect($.fn.removeClass).toHaveBeenCalledWith("is-disabled");
|
||||
}
|
||||
|
||||
// make sure the link finishes without the `is-disabled` class
|
||||
expect(link).not.toHaveClass("is-disabled");
|
||||
|
||||
// affirm that ajax was called
|
||||
expect($.ajax).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
};
|
||||
for (var i = 0; i < draft_states.length; i++) {
|
||||
test_link_disabled_during_ajax_call(draft_states[i]);
|
||||
};
|
||||
for (i = 0; i < draft_states.length; i++) {
|
||||
test_link_disabled_during_ajax_call(draft_states[i]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ define([ "jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helper
|
||||
var mockXBlockEditorHtml;
|
||||
|
||||
beforeEach(function () {
|
||||
edit_helpers.installMockXBlock(mockSaveResponse);
|
||||
edit_helpers.installMockXBlock();
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
define(["sinon"], function(sinon) {
|
||||
define(["sinon", "underscore"], function(sinon, _) {
|
||||
var fakeServer, fakeRequests, respondWithJson, respondWithError;
|
||||
|
||||
/* These utility methods are used by Jasmine tests to create a mock server or
|
||||
@@ -46,14 +46,18 @@ define(["sinon"], function(sinon) {
|
||||
};
|
||||
|
||||
respondWithJson = function(requests, jsonResponse, requestIndex) {
|
||||
requestIndex = requestIndex || requests.length - 1;
|
||||
if (_.isUndefined(requestIndex)) {
|
||||
requestIndex = requests.length - 1;
|
||||
}
|
||||
requests[requestIndex].respond(200,
|
||||
{ "Content-Type": "application/json" },
|
||||
JSON.stringify(jsonResponse));
|
||||
};
|
||||
|
||||
respondWithError = function(requests, requestIndex) {
|
||||
requestIndex = requestIndex || requests.length - 1;
|
||||
if (_.isUndefined(requestIndex)) {
|
||||
requestIndex = requests.length - 1;
|
||||
}
|
||||
requests[requestIndex].respond(500,
|
||||
{ "Content-Type": "application/json" },
|
||||
JSON.stringify({ }));
|
||||
|
||||
@@ -2,22 +2,14 @@
|
||||
* Provides helper methods for invoking Studio editors in Jasmine tests.
|
||||
*/
|
||||
define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers/modal_helpers",
|
||||
"js/views/modals/edit_xblock", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
|
||||
function($, _, create_sinon, modal_helpers, EditXBlockModal) {
|
||||
"js/views/modals/edit_xblock", "js/collections/component_template",
|
||||
"xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
|
||||
function($, _, create_sinon, modal_helpers, EditXBlockModal, ComponentTemplates) {
|
||||
|
||||
var editorTemplate = readFixtures('metadata-editor.underscore'),
|
||||
numberEntryTemplate = readFixtures('metadata-number-entry.underscore'),
|
||||
stringEntryTemplate = readFixtures('metadata-string-entry.underscore'),
|
||||
editXBlockModalTemplate = readFixtures('edit-xblock-modal.underscore'),
|
||||
editorModeButtonTemplate = readFixtures('editor-mode-button.underscore'),
|
||||
installMockXBlock,
|
||||
uninstallMockXBlock,
|
||||
installMockXModule,
|
||||
uninstallMockXModule,
|
||||
installEditTemplates,
|
||||
showEditModal;
|
||||
var installMockXBlock, uninstallMockXBlock, installMockXModule, uninstallMockXModule,
|
||||
mockComponentTemplates, installEditTemplates, showEditModal, verifyXBlockRequest;
|
||||
|
||||
installMockXBlock = function(mockResult) {
|
||||
installMockXBlock = function() {
|
||||
window.MockXBlock = function(runtime, element) {
|
||||
return {
|
||||
runtime: runtime
|
||||
@@ -41,17 +33,52 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
|
||||
window.MockDescriptor = null;
|
||||
};
|
||||
|
||||
mockComponentTemplates = new ComponentTemplates([
|
||||
{
|
||||
templates: [
|
||||
{
|
||||
category: 'discussion',
|
||||
display_name: 'Discussion'
|
||||
}],
|
||||
type: 'discussion'
|
||||
}, {
|
||||
"templates": [
|
||||
{
|
||||
"category": "html",
|
||||
"boilerplate_name": null,
|
||||
"display_name": "Text"
|
||||
}, {
|
||||
"category": "html",
|
||||
"boilerplate_name": "announcement.yaml",
|
||||
"display_name": "Announcement"
|
||||
}, {
|
||||
"category": "html",
|
||||
"boilerplate_name": "raw.yaml",
|
||||
"display_name": "Raw HTML"
|
||||
}],
|
||||
"type": "html"
|
||||
}],
|
||||
{
|
||||
parse: true
|
||||
});
|
||||
|
||||
installEditTemplates = function(append) {
|
||||
modal_helpers.installModalTemplates(append);
|
||||
|
||||
// Add templates needed by the add XBlock menu
|
||||
modal_helpers.installTemplate('add-xblock-component');
|
||||
modal_helpers.installTemplate('add-xblock-component-button');
|
||||
modal_helpers.installTemplate('add-xblock-component-menu');
|
||||
modal_helpers.installTemplate('add-xblock-component-menu-problem');
|
||||
|
||||
// Add templates needed by the edit XBlock modal
|
||||
appendSetFixtures($("<script>", { id: "edit-xblock-modal-tpl", type: "text/template" }).text(editXBlockModalTemplate));
|
||||
appendSetFixtures($("<script>", { id: "editor-mode-button-tpl", type: "text/template" }).text(editorModeButtonTemplate));
|
||||
modal_helpers.installTemplate('edit-xblock-modal');
|
||||
modal_helpers.installTemplate('editor-mode-button');
|
||||
|
||||
// Add templates needed by the settings editor
|
||||
appendSetFixtures($("<script>", {id: "metadata-editor-tpl", type: "text/template"}).text(editorTemplate));
|
||||
appendSetFixtures($("<script>", {id: "metadata-number-entry", type: "text/template"}).text(numberEntryTemplate));
|
||||
appendSetFixtures($("<script>", {id: "metadata-string-entry", type: "text/template"}).text(stringEntryTemplate));
|
||||
modal_helpers.installTemplate('metadata-editor');
|
||||
modal_helpers.installTemplate('metadata-number-entry');
|
||||
modal_helpers.installTemplate('metadata-string-entry');
|
||||
};
|
||||
|
||||
showEditModal = function(requests, xblockElement, model, mockHtml, options) {
|
||||
@@ -64,12 +91,22 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
|
||||
return modal;
|
||||
};
|
||||
|
||||
verifyXBlockRequest = function (requests, expectedJson) {
|
||||
var request = requests[requests.length - 1],
|
||||
actualJson = JSON.parse(request.requestBody);
|
||||
expect(request.url).toEqual("/xblock/");
|
||||
expect(request.method).toEqual("POST");
|
||||
expect(actualJson).toEqual(expectedJson);
|
||||
};
|
||||
|
||||
return $.extend(modal_helpers, {
|
||||
'installMockXBlock': installMockXBlock,
|
||||
'uninstallMockXBlock': uninstallMockXBlock,
|
||||
'installMockXModule': installMockXModule,
|
||||
'uninstallMockXModule': uninstallMockXModule,
|
||||
'mockComponentTemplates': mockComponentTemplates,
|
||||
'installEditTemplates': installEditTemplates,
|
||||
'showEditModal': showEditModal
|
||||
'showEditModal': showEditModal,
|
||||
'verifyXBlockRequest': verifyXBlockRequest
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
*/
|
||||
define(["jquery", "js/spec_helpers/view_helpers"],
|
||||
function($, view_helpers) {
|
||||
var basicModalTemplate = readFixtures('basic-modal.underscore'),
|
||||
modalButtonTemplate = readFixtures('modal-button.underscore'),
|
||||
feedbackTemplate = readFixtures('system-feedback.underscore'),
|
||||
installModalTemplates,
|
||||
var installModalTemplates,
|
||||
getModalElement,
|
||||
isShowingModal,
|
||||
hideModalIfShowing,
|
||||
@@ -15,8 +12,8 @@ define(["jquery", "js/spec_helpers/view_helpers"],
|
||||
|
||||
installModalTemplates = function(append) {
|
||||
view_helpers.installViewTemplates(append);
|
||||
appendSetFixtures($("<script>", { id: "basic-modal-tpl", type: "text/template" }).text(basicModalTemplate));
|
||||
appendSetFixtures($("<script>", { id: "modal-button-tpl", type: "text/template" }).text(modalButtonTemplate));
|
||||
view_helpers.installTemplate('basic-modal');
|
||||
view_helpers.installTemplate('modal-button');
|
||||
};
|
||||
|
||||
getModalElement = function(modal) {
|
||||
|
||||
@@ -1,20 +1,49 @@
|
||||
/**
|
||||
* Provides helper methods for invoking Studio modal windows in Jasmine tests.
|
||||
*/
|
||||
define(["jquery"],
|
||||
function($) {
|
||||
var feedbackTemplate = readFixtures('system-feedback.underscore'),
|
||||
installViewTemplates;
|
||||
define(["jquery", "js/views/feedback_notification", "js/spec_helpers/create_sinon"],
|
||||
function($, NotificationView, create_sinon) {
|
||||
var installTemplate, installViewTemplates, createNotificationSpy, verifyNotificationShowing,
|
||||
verifyNotificationHidden;
|
||||
|
||||
installViewTemplates = function(append) {
|
||||
if (append) {
|
||||
appendSetFixtures($("<script>", { id: "system-feedback-tpl", type: "text/template" }).text(feedbackTemplate));
|
||||
installTemplate = function(templateName, isFirst) {
|
||||
var template = readFixtures(templateName + '.underscore'),
|
||||
templateId = templateName + '-tpl';
|
||||
if (isFirst) {
|
||||
setFixtures($("<script>", { id: templateId, type: "text/template" }).text(template));
|
||||
} else {
|
||||
setFixtures($("<script>", { id: "system-feedback-tpl", type: "text/template" }).text(feedbackTemplate));
|
||||
appendSetFixtures($("<script>", { id: templateId, type: "text/template" }).text(template));
|
||||
}
|
||||
};
|
||||
|
||||
installViewTemplates = function(append) {
|
||||
installTemplate('system-feedback', !append);
|
||||
appendSetFixtures('<div id="page-notification"></div>');
|
||||
};
|
||||
|
||||
createNotificationSpy = function() {
|
||||
var notificationSpy = spyOnConstructor(NotificationView, "Mini", ["show", "hide"]);
|
||||
notificationSpy.show.andReturn(notificationSpy);
|
||||
return notificationSpy;
|
||||
};
|
||||
|
||||
verifyNotificationShowing = function(notificationSpy, text) {
|
||||
expect(notificationSpy.constructor).toHaveBeenCalled();
|
||||
expect(notificationSpy.show).toHaveBeenCalled();
|
||||
expect(notificationSpy.hide).not.toHaveBeenCalled();
|
||||
var options = notificationSpy.constructor.mostRecentCall.args[0];
|
||||
expect(options.title).toMatch(text);
|
||||
};
|
||||
|
||||
verifyNotificationHidden = function(notificationSpy) {
|
||||
expect(notificationSpy.hide).toHaveBeenCalled();
|
||||
};
|
||||
|
||||
return {
|
||||
'installViewTemplates': installViewTemplates
|
||||
'installTemplate': installTemplate,
|
||||
'installViewTemplates': installViewTemplates,
|
||||
'createNotificationSpy': createNotificationSpy,
|
||||
'verifyNotificationShowing': verifyNotificationShowing,
|
||||
'verifyNotificationHidden': verifyNotificationHidden
|
||||
};
|
||||
});
|
||||
|
||||
@@ -7,15 +7,15 @@
|
||||
* getUpdateUrl: a utility method that returns the xblock update URL, appending
|
||||
* the location if passed in.
|
||||
*/
|
||||
define([], function () {
|
||||
define(["underscore"], function (_) {
|
||||
var urlRoot = '/xblock';
|
||||
|
||||
var getUpdateUrl = function (locator) {
|
||||
if (locator === undefined) {
|
||||
return urlRoot + "/";
|
||||
if (_.isUndefined(locator)) {
|
||||
return urlRoot + '/';
|
||||
}
|
||||
else {
|
||||
return urlRoot + "/" + locator;
|
||||
return urlRoot + '/' + locator;
|
||||
}
|
||||
};
|
||||
return {
|
||||
@@ -23,4 +23,3 @@ define([], function () {
|
||||
getUpdateUrl: getUpdateUrl
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
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,11 +1,13 @@
|
||||
define(["jquery", "underscore", "gettext", "js/views/paging", "js/views/asset", "js/views/paging_header", "js/views/paging_footer"],
|
||||
function($, _, gettext, PagingView, AssetView, PagingHeader, PagingFooter) {
|
||||
define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging", "js/views/asset",
|
||||
"js/views/paging_header", "js/views/paging_footer", "js/utils/modal"],
|
||||
function($, _, gettext, AssetModel, PagingView, AssetView, PagingHeader, PagingFooter, ModalUtils) {
|
||||
|
||||
var AssetsView = PagingView.extend({
|
||||
// takes AssetCollection as model
|
||||
|
||||
events : {
|
||||
"click .column-sort-link": "onToggleColumn"
|
||||
"click .column-sort-link": "onToggleColumn",
|
||||
"click .upload-button": "showUploadModal"
|
||||
},
|
||||
|
||||
initialize : function() {
|
||||
@@ -17,6 +19,8 @@ define(["jquery", "underscore", "gettext", "js/views/paging", "js/views/asset",
|
||||
this.registerSortableColumn('js-asset-date-col', gettext('Date Added'), 'date_added', 'desc');
|
||||
this.setInitialSortColumn('js-asset-date-col');
|
||||
this.showLoadingIndicator();
|
||||
this.setPage(0);
|
||||
assetsView = this;
|
||||
},
|
||||
|
||||
render: function() {
|
||||
@@ -24,6 +28,14 @@ define(["jquery", "underscore", "gettext", "js/views/paging", "js/views/asset",
|
||||
return this;
|
||||
},
|
||||
|
||||
afterRender: function(){
|
||||
// Bind events with html elements
|
||||
$('li a.upload-button').on('click', this.showUploadModal);
|
||||
$('.upload-modal .close-button').on('click', this.hideModal);
|
||||
$('.upload-modal .choose-file-button').on('click', this.showFileSelectionMenu);
|
||||
return this;
|
||||
},
|
||||
|
||||
getTableBody: function() {
|
||||
var tableBody = this.tableBody;
|
||||
if (!tableBody) {
|
||||
@@ -47,9 +59,9 @@ define(["jquery", "underscore", "gettext", "js/views/paging", "js/views/asset",
|
||||
|
||||
renderPageItems: function() {
|
||||
var self = this,
|
||||
assets = this.collection,
|
||||
hasAssets = assets.length > 0,
|
||||
tableBody = this.getTableBody();
|
||||
assets = this.collection,
|
||||
hasAssets = assets.length > 0,
|
||||
tableBody = this.getTableBody();
|
||||
tableBody.empty();
|
||||
if (hasAssets) {
|
||||
assets.each(
|
||||
@@ -91,6 +103,92 @@ define(["jquery", "underscore", "gettext", "js/views/paging", "js/views/asset",
|
||||
onToggleColumn: function(event) {
|
||||
var columnName = event.target.id;
|
||||
this.toggleSortOrder(columnName);
|
||||
},
|
||||
|
||||
hideModal: function (event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
$('.file-input').unbind('change.startUpload');
|
||||
ModalUtils.hideModal();
|
||||
},
|
||||
|
||||
showUploadModal: function (event) {
|
||||
var self = assetsView;
|
||||
event.preventDefault();
|
||||
self.resetUploadModal();
|
||||
ModalUtils.showModal();
|
||||
$('.file-input').bind('change', self.startUpload);
|
||||
$('.upload-modal .file-chooser').fileupload({
|
||||
dataType: 'json',
|
||||
type: 'POST',
|
||||
maxChunkSize: 100 * 1000 * 1000, // 100 MB
|
||||
autoUpload: true,
|
||||
progressall: function(event, data) {
|
||||
var percentComplete = parseInt((100 * data.loaded) / data.total, 10);
|
||||
self.showUploadFeedback(event, percentComplete);
|
||||
},
|
||||
maxFileSize: 100 * 1000 * 1000, // 100 MB
|
||||
maxNumberofFiles: 100,
|
||||
add: function(event, data) {
|
||||
data.process().done(function () {
|
||||
data.submit();
|
||||
});
|
||||
},
|
||||
done: function(event, data) {
|
||||
self.displayFinishedUpload(data.result);
|
||||
}
|
||||
|
||||
});
|
||||
},
|
||||
|
||||
showFileSelectionMenu: function(event) {
|
||||
event.preventDefault();
|
||||
$('.file-input').click();
|
||||
},
|
||||
|
||||
startUpload: function (event) {
|
||||
var file = event.target.value;
|
||||
|
||||
$('.upload-modal h1').text(gettext('Uploading…'));
|
||||
$('.upload-modal .file-name').html(file.substring(file.lastIndexOf("\\") + 1));
|
||||
$('.upload-modal .choose-file-button').hide();
|
||||
$('.upload-modal .progress-bar').removeClass('loaded').show();
|
||||
},
|
||||
|
||||
resetUploadModal: function () {
|
||||
// Reset modal so it no longer displays information about previously
|
||||
// completed uploads.
|
||||
var percentVal = '0%';
|
||||
$('.upload-modal .progress-fill').width(percentVal);
|
||||
$('.upload-modal .progress-fill').html(percentVal);
|
||||
$('.upload-modal .progress-bar').hide();
|
||||
|
||||
$('.upload-modal .file-name').show();
|
||||
$('.upload-modal .file-name').html('');
|
||||
$('.upload-modal .choose-file-button').text(gettext('Choose File'));
|
||||
$('.upload-modal .embeddable-xml-input').val('');
|
||||
$('.upload-modal .embeddable').hide();
|
||||
},
|
||||
|
||||
showUploadFeedback: function (event, percentComplete) {
|
||||
var percentVal = percentComplete + '%';
|
||||
$('.upload-modal .progress-fill').width(percentVal);
|
||||
$('.upload-modal .progress-fill').html(percentVal);
|
||||
},
|
||||
|
||||
displayFinishedUpload: function (resp) {
|
||||
var asset = resp.asset;
|
||||
|
||||
$('.upload-modal h1').text(gettext('Upload New File'));
|
||||
$('.upload-modal .embeddable-xml-input').val(asset.portable_url);
|
||||
$('.upload-modal .embeddable').show();
|
||||
$('.upload-modal .file-name').hide();
|
||||
$('.upload-modal .progress-fill').html(resp.msg);
|
||||
$('.upload-modal .choose-file-button').text(gettext('Load Another File')).show();
|
||||
$('.upload-modal .progress-fill').width('100%');
|
||||
|
||||
assetsView.addAsset(new AssetModel(asset));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -38,12 +41,12 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
|
||||
// avoid creating an orphan if the addition fails.
|
||||
if (newParent) {
|
||||
removeFromParent = oldParent;
|
||||
self.reorder(newParent, function () {
|
||||
self.reorder(removeFromParent, hideSaving);
|
||||
self.updateChildren(newParent, function () {
|
||||
self.updateChildren(removeFromParent, hideSaving);
|
||||
});
|
||||
} else {
|
||||
// No new parent, only reordering within same container.
|
||||
self.reorder(oldParent, hideSaving);
|
||||
self.updateChildren(oldParent, hideSaving);
|
||||
}
|
||||
|
||||
oldParent = undefined;
|
||||
@@ -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,20 +72,20 @@ 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"
|
||||
|
||||
});
|
||||
},
|
||||
|
||||
reorder: function (targetParent, successCallback) {
|
||||
updateChildren: 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,179 @@ 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: false})
|
||||
}).success(function() {
|
||||
deleting.hide();
|
||||
// get the parent so we can remove this component from its parent.
|
||||
var parent = self.findXBlockElement(xblockElement.parent());
|
||||
xblockElement.remove();
|
||||
self.xblockView.updateChildren(parent);
|
||||
});
|
||||
}
|
||||
},
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,112 +19,16 @@
|
||||
|
||||
<%block name="jsextra">
|
||||
<script type="text/javascript">
|
||||
require(["domReady", "jquery", "js/models/asset", "js/collections/asset",
|
||||
"js/views/assets", "js/views/feedback_prompt",
|
||||
"js/views/feedback_notification", "js/views/paging_header", "js/views/paging_footer",
|
||||
"js/utils/modal", "jquery.fileupload"],
|
||||
function(domReady, $, AssetModel, AssetCollection, AssetsView, PromptView, NotificationView,
|
||||
PagingHeader, PagingFooter, ModalUtils) {
|
||||
var assets = new AssetCollection();
|
||||
require(["jquery", "js/collections/asset", "js/views/assets", "jquery.fileupload"],
|
||||
function($, AssetCollection, AssetsView) {
|
||||
|
||||
assets.url = "${asset_callback_url}";
|
||||
var assetsView = new AssetsView({collection: assets, el: $('.assets-wrapper')});
|
||||
assetsView.render();
|
||||
assetsView.setPage(0);
|
||||
var assets = new AssetCollection();
|
||||
assets.url = "${asset_callback_url}";
|
||||
var assetsView = new AssetsView({collection: assets, el: $('.assets-wrapper')});
|
||||
assetsView.render();
|
||||
|
||||
var hideModal = function (e) {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
$('.file-input').unbind('change.startUpload');
|
||||
ModalUtils.hideModal();
|
||||
};
|
||||
|
||||
var showUploadModal = function (e) {
|
||||
e.preventDefault();
|
||||
resetUploadModal();
|
||||
ModalUtils.showModal();
|
||||
$('.file-input').bind('change', startUpload);
|
||||
$('.upload-modal .file-chooser').fileupload({
|
||||
dataType: 'json',
|
||||
type: 'POST',
|
||||
maxChunkSize: 100 * 1000 * 1000, // 100 MB
|
||||
autoUpload: true,
|
||||
progressall: function(e, data) {
|
||||
var percentComplete = parseInt((100 * data.loaded) / data.total, 10);
|
||||
showUploadFeedback(e, percentComplete);
|
||||
},
|
||||
maxFileSize: 100 * 1000 * 1000, // 100 MB
|
||||
maxNumberofFiles: 100,
|
||||
add: function(e, data) {
|
||||
data.process().done(function () {
|
||||
data.submit();
|
||||
});
|
||||
},
|
||||
done: function(e, data) {
|
||||
displayFinishedUpload(data.result);
|
||||
}
|
||||
|
||||
});
|
||||
};
|
||||
|
||||
var showFileSelectionMenu = function(e) {
|
||||
e.preventDefault();
|
||||
$('.file-input').click();
|
||||
};
|
||||
|
||||
var startUpload = function (e) {
|
||||
var file = e.target.value;
|
||||
|
||||
$('.upload-modal h1').text("${_(u'Uploading…')}");
|
||||
$('.upload-modal .file-name').html(file.substring(file.lastIndexOf("\\") + 1));
|
||||
$('.upload-modal .choose-file-button').hide();
|
||||
$('.upload-modal .progress-bar').removeClass('loaded').show();
|
||||
};
|
||||
|
||||
var resetUploadModal = function () {
|
||||
// Reset modal so it no longer displays information about previously
|
||||
// completed uploads.
|
||||
var percentVal = '0%';
|
||||
$('.upload-modal .progress-fill').width(percentVal);
|
||||
$('.upload-modal .progress-fill').html(percentVal);
|
||||
$('.upload-modal .progress-bar').hide();
|
||||
|
||||
$('.upload-modal .file-name').show();
|
||||
$('.upload-modal .file-name').html('');
|
||||
$('.upload-modal .choose-file-button').text("${_('Choose File')}");
|
||||
$('.upload-modal .embeddable-xml-input').val('');
|
||||
$('.upload-modal .embeddable').hide();
|
||||
};
|
||||
|
||||
var showUploadFeedback = function (event, percentComplete) {
|
||||
var percentVal = percentComplete + '%';
|
||||
$('.upload-modal .progress-fill').width(percentVal);
|
||||
$('.upload-modal .progress-fill').html(percentVal);
|
||||
};
|
||||
|
||||
var displayFinishedUpload = function (resp) {
|
||||
var asset = resp.asset;
|
||||
|
||||
$('.upload-modal h1').text("${_('Upload New File')}");
|
||||
$('.upload-modal .embeddable-xml-input').val(asset.portable_url);
|
||||
$('.upload-modal .embeddable').show();
|
||||
$('.upload-modal .file-name').hide();
|
||||
$('.upload-modal .progress-fill').html(resp.msg);
|
||||
$('.upload-modal .choose-file-button').text("${_('Load Another File')}").show();
|
||||
$('.upload-modal .progress-fill').width('100%');
|
||||
|
||||
assetsView.addAsset(new AssetModel(asset));
|
||||
};
|
||||
|
||||
domReady(function() {
|
||||
$('.uploads .upload-button').bind('click', showUploadModal);
|
||||
$('.upload-modal .close-button').bind('click', hideModal);
|
||||
$('.upload-modal .choose-file-button').bind('click', showFileSelectionMenu);
|
||||
});
|
||||
|
||||
}); // end of require()
|
||||
</script>
|
||||
}); // end of require()
|
||||
</script>
|
||||
</%block>
|
||||
|
||||
<%block name="content">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.translation import ugettext as _
|
||||
%>
|
||||
<%block name="title">Course Checklists</%block>
|
||||
<%block name="title">${_("Course Checklists")}</%block>
|
||||
<%block name="bodyclass">is-signedin course view-checklists</%block>
|
||||
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
@@ -64,7 +64,7 @@ require(["domReady!", "jquery", "js/collections/checklist", "js/views/checklist"
|
||||
</div>
|
||||
|
||||
<div class="bit">
|
||||
<h3 class="title title-3">Studio checklists</h3>
|
||||
<h3 class="title title-3">${_("Studio checklists")}</h3>
|
||||
<nav class="nav-page checklists-current">
|
||||
<ol>
|
||||
% for checklist in checklists:
|
||||
|
||||
@@ -26,7 +26,5 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
% if not xblock_context['read_only']:
|
||||
<span data-tooltip="${_("Drag to reorder")}" class="drag-handle"></span>
|
||||
% endif
|
||||
<span data-tooltip="${_("Drag to reorder")}" class="drag-handle action"></span>
|
||||
${preview}
|
||||
|
||||
@@ -31,15 +31,18 @@ main_xblock_info = {
|
||||
%>
|
||||
<script type='text/javascript'>
|
||||
require(["domReady!", "jquery", "js/models/xblock_info", "js/views/pages/container",
|
||||
"xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
|
||||
function(doc, $, XBlockInfo, ContainerPage) {
|
||||
"js/collections/component_template", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
|
||||
function(doc, $, XBlockInfo, ContainerPage, ComponentTemplates) {
|
||||
var view, mainXBlockInfo;
|
||||
|
||||
var templates = new ComponentTemplates(${component_templates | n}, {parse: true});
|
||||
|
||||
mainXBlockInfo = new XBlockInfo(${json.dumps(main_xblock_info) | n});
|
||||
|
||||
view = new ContainerPage({
|
||||
el: $('#content'),
|
||||
model: mainXBlockInfo
|
||||
model: mainXBlockInfo,
|
||||
templates: templates
|
||||
});
|
||||
view.render();
|
||||
});
|
||||
@@ -80,7 +83,7 @@ main_xblock_info = {
|
||||
<section class="content-area">
|
||||
|
||||
<article class="content-primary window">
|
||||
<section class="wrapper-xblock level-page is-hidden" data-locator="${xblock_locator}" data-course-key="${xblock_locator.course_key}">
|
||||
<section class="wrapper-xblock level-page is-hidden studio-xblock-wrapper" data-locator="${xblock_locator}" data-course-key="${xblock_locator.course_key}">
|
||||
</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>
|
||||
19
cms/templates/js/asset-upload-modal.underscore
Normal file
19
cms/templates/js/asset-upload-modal.underscore
Normal file
@@ -0,0 +1,19 @@
|
||||
<div class="upload-modal modal" style="display: none;">
|
||||
<a href="#" class="close-button"><i class="icon-remove-sign"></i> <span class="sr"><%= gettext('close') %></span></a>
|
||||
<div class="modal-body">
|
||||
<h1 class="title"><%= gettext("Upload New File") %></h1>
|
||||
<p class="file-name">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill"></div>
|
||||
</div>
|
||||
<div class="embeddable">
|
||||
<label>URL:</label>
|
||||
<input type="text" class="embeddable-xml-input" value='' readonly>
|
||||
</div>
|
||||
<form class="file-chooser" action="asset-url"
|
||||
method="post" enctype="multipart/form-data">
|
||||
<a href="#" class="choose-file-button"><%= gettext("Choose File") %></a>
|
||||
<input type="file" class="file-input" name="file">
|
||||
</form>
|
||||
</div>
|
||||
</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="${xblock.location}">
|
||||
% else:
|
||||
<div class="studio-xblock-wrapper">
|
||||
% endif
|
||||
<section class="wrapper-xblock xblock-type-container level-element" data-locator="${xblock.location}">
|
||||
<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,57 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%! from django.conf import settings %>
|
||||
|
||||
% if xblock.location != xblock_context['root_xblock'].location:
|
||||
<% section_class = "level-nesting" if xblock.has_children else "level-element" %>
|
||||
<section class="wrapper-xblock ${section_class}" data-locator="${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="${xblock.location}">
|
||||
% else:
|
||||
<div class="studio-xblock-wrapper" data-locator="${xblock.location}">
|
||||
% 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}" data-course-key="${xblock.location.course_key}">
|
||||
% endif
|
||||
|
||||
<header class="xblock-header">
|
||||
<div class="header-details">
|
||||
${xblock.display_name_with_default | h}
|
||||
% if xblock.has_children:
|
||||
<a href="#" data-tooltip="${_('Expand or Collapse')}" class="action expand-collapse collapse">
|
||||
<i class="icon-caret-down ui-toggle-expansion"></i>
|
||||
<span class="sr">${_('Expand or Collapse')}</span>
|
||||
</a>
|
||||
% endif
|
||||
<span>${xblock.display_name_with_default | h}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<ul class="actions-list">
|
||||
% if not xblock_context['read_only']:
|
||||
% if not xblock.has_children:
|
||||
<li class="action-item action-edit">
|
||||
<a href="#" class="edit-button action-button">
|
||||
<i class="icon-pencil"></i>
|
||||
<span class="action-button-text">${_("Edit")}</span>
|
||||
</a>
|
||||
</li>
|
||||
% endif
|
||||
%if settings.FEATURES.get('ENABLE_DUPLICATE_XBLOCK_LEAF_COMPONENT'):
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" data-tooltip="${_("Duplicate")}" class="duplicate-button action-button">
|
||||
<i class="icon-copy"></i>
|
||||
<span class="sr">${_("Duplicate")}</span>
|
||||
</a>
|
||||
</li>
|
||||
% endif
|
||||
%if settings.FEATURES.get('ENABLE_DELETE_XBLOCK_LEAF_COMPONENT'):
|
||||
<li class="action-item action-delete">
|
||||
<a href="#" data-tooltip="${_("Delete")}" class="delete-button action-button">
|
||||
<i class="icon-trash"></i>
|
||||
<span class="sr">${_("Delete")}</span>
|
||||
</a>
|
||||
</li>
|
||||
% endif
|
||||
% if not is_root and is_reorderable:
|
||||
<li class="action-item action-drag">
|
||||
<span data-tooltip="${_('Drag to reorder')}" class="drag-handle action"></span>
|
||||
</li>
|
||||
% endif
|
||||
% endif
|
||||
</ul>
|
||||
</div>
|
||||
@@ -43,6 +60,11 @@
|
||||
${content}
|
||||
</article>
|
||||
|
||||
% if xblock.location != xblock_context['root_xblock'].location:
|
||||
</section>
|
||||
% if not is_root:
|
||||
</section>
|
||||
% if is_reorderable:
|
||||
</li>
|
||||
% else:
|
||||
</div>
|
||||
% endif
|
||||
% endif
|
||||
|
||||
@@ -20,21 +20,21 @@ 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) {
|
||||
window.unit_location_analytics = '${unit_locator}';
|
||||
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_usage_key}';
|
||||
|
||||
// tabs
|
||||
$('.tab-group').tabs();
|
||||
var templates = new ComponentTemplates(${component_templates | n}, {parse: true});
|
||||
|
||||
new UnitEditView({
|
||||
el: $('.main-wrapper'),
|
||||
view: 'unit',
|
||||
model: new ModuleModel({
|
||||
id: '${unit_locator}',
|
||||
id: '${unit_usage_key}',
|
||||
state: '${unit_state}'
|
||||
})
|
||||
}),
|
||||
templates: templates
|
||||
});
|
||||
|
||||
$('.new-component-template').each(function(){
|
||||
@@ -46,7 +46,7 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
|
||||
</%block>
|
||||
|
||||
<%block name="content">
|
||||
<div class="main-wrapper edit-state-${unit_state}" data-locator="${unit_locator}" data-course-key="${unit_locator.course_key}">
|
||||
<div class="main-wrapper edit-state-${unit_state}" data-locator="${unit_usage_key}" data-course-key="${unit_usage_key.course_key}">
|
||||
<div class="inner-wrapper">
|
||||
<div class="alert editing-draft-alert">
|
||||
<p class="alert-message"><strong>${_("You are editing a draft.")}</strong>
|
||||
@@ -60,90 +60,11 @@ require(["domReady!", "jquery", "js/models/module_info", "coffee/src/views/unit"
|
||||
<article class="unit-body window">
|
||||
<p class="unit-name-input"><label for="unit-display-name-input">${_("Display Name:")}</label><input type="text" value="${unit.display_name_with_default | h}" id="unit-display-name-input" class="unit-display-name-input" /></p>
|
||||
<ol class="components">
|
||||
% for xblock in xblocks:
|
||||
<li class="component" data-locator="${xblock.location}" data-course-key="${xblock.location.course_key}"/>
|
||||
% for usage_key in child_usage_keys:
|
||||
<li class="component" data-locator="${usage_key}" data-course-key="${usage_key.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
|
||||
@@ -82,21 +82,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
|
||||
|
||||
@@ -111,7 +102,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,26 +1856,3 @@ 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})
|
||||
|
||||
|
||||
@login_required
|
||||
def token(request):
|
||||
'''
|
||||
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.
|
||||
'''
|
||||
course_id = SlashSeparatedCourseKey.from_deprecated_string(request.GET.get("course_id"))
|
||||
course = course_from_id(course_id)
|
||||
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)
|
||||
secret = course.annotation_token_secret
|
||||
custom_data = {"issuedAt": newtime, "consumerKey": secret, "userId": request.user.email, "ttl": 86400}
|
||||
newtoken = create_token(secret, custom_data)
|
||||
response = HttpResponse(newtoken, mimetype="text/plain")
|
||||
return response
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<section id="inputtype_${id}" class="capa_inputtype" >
|
||||
<div id="inputtype_${id}" class="capa_inputtype">
|
||||
<div class="drag_and_drop_problem_div" id="drag_and_drop_div_${id}"
|
||||
data-plain-id="${id}">
|
||||
</div>
|
||||
@@ -29,4 +29,4 @@
|
||||
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
|
||||
</div>
|
||||
% endif
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,9 +10,12 @@ import textwrap
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Make '_' a no-op so we can scrape strings
|
||||
_ = lambda text: text
|
||||
|
||||
|
||||
class AnnotatableFields(object):
|
||||
data = String(help="XML data for the annotation", scope=Scope.content,
|
||||
data = String(help=_("XML data for the annotation"), scope=Scope.content,
|
||||
default=textwrap.dedent(
|
||||
"""\
|
||||
<annotatable>
|
||||
@@ -32,8 +35,8 @@ class AnnotatableFields(object):
|
||||
</annotatable>
|
||||
"""))
|
||||
display_name = String(
|
||||
display_name="Display Name",
|
||||
help="Display name for this module",
|
||||
display_name=_("Display Name"),
|
||||
help=_("Display name for this module"),
|
||||
scope=Scope.settings,
|
||||
default='Annotation',
|
||||
)
|
||||
|
||||
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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user