Commit 715aa698 by David Baumgold

Merge pull request #662 from edx/db/requirejs

requirejs in Studio
parents a47442ed d97921e6
...@@ -17,6 +17,8 @@ the Check/Final Check buttons with keys: custom_check and custom_final_check ...@@ -17,6 +17,8 @@ the Check/Final Check buttons with keys: custom_check and custom_final_check
LMS: Add PaidCourseRegistration mode, where payment is required before course LMS: Add PaidCourseRegistration mode, where payment is required before course
registration. registration.
Studio: Switched to loading Javascript using require.js
LMS: Add split testing functionality for internal use. LMS: Add split testing functionality for internal use.
CMS: Add edit_course_tabs management command, providing a primitive CMS: Add edit_course_tabs management command, providing a primitive
...@@ -36,7 +38,7 @@ new post dropdown as well as response and comment area labeling. ...@@ -36,7 +38,7 @@ new post dropdown as well as response and comment area labeling.
LMS: enhanced shib support, including detection of linked shib account LMS: enhanced shib support, including detection of linked shib account
at login page and support for the ?next= GET parameter. at login page and support for the ?next= GET parameter.
LMS: Experimental feature using the ICE change tracker JS pkg to allow peer LMS: Experimental feature using the ICE change tracker JS pkg to allow peer
assessors to edit the original submitter's work. assessors to edit the original submitter's work.
LMS: Fixed a bug that caused links from forum user profile pages to LMS: Fixed a bug that caused links from forum user profile pages to
...@@ -341,4 +343,4 @@ Common: Allow setting of authentication session cookie name. ...@@ -341,4 +343,4 @@ Common: Allow setting of authentication session cookie name.
LMS: Option to email students when enroll/un-enroll them. LMS: Option to email students when enroll/un-enroll them.
Blades: Added WAI-ARIA markup to the video player controls. These are now fully Blades: Added WAI-ARIA markup to the video player controls. These are now fully
accessible by screen readers. accessible by screen readers.
...@@ -16,6 +16,11 @@ def i_select_advanced_settings(step): ...@@ -16,6 +16,11 @@ def i_select_advanced_settings(step):
world.click_course_settings() world.click_course_settings()
link_css = 'li.nav-course-settings-advanced a' link_css = 'li.nav-course-settings-advanced a'
world.css_click(link_css) world.css_click(link_css)
world.wait_for_requirejs(
["jquery", "js/models/course", "js/models/settings/advanced",
"js/views/settings/advanced", "codemirror"])
# this shouldn't be necessary, but we experience sporadic failures otherwise
world.wait(1)
@step('I am on the Advanced Course Settings page in Studio$') @step('I am on the Advanced Course Settings page in Studio$')
...@@ -91,8 +96,10 @@ def assert_policy_entries(expected_keys, expected_values): ...@@ -91,8 +96,10 @@ def assert_policy_entries(expected_keys, expected_values):
index = get_index_of(key) index = get_index_of(key)
assert_false(index == -1, "Could not find key: {key}".format(key=key)) assert_false(index == -1, "Could not find key: {key}".format(key=key))
found_value = world.css_find(VALUE_CSS)[index].value found_value = world.css_find(VALUE_CSS)[index].value
assert_equal(value, found_value, assert_equal(
"Expected {} to have value {} but found {}".format(key, value, found_value)) value, found_value,
"Expected {} to have value {} but found {}".format(key, value, found_value)
)
def get_index_of(expected_key): def get_index_of(expected_key):
...@@ -116,4 +123,6 @@ def change_display_name_value(step, new_value): ...@@ -116,4 +123,6 @@ def change_display_name_value(step, new_value):
def change_value(step, key, new_value): def change_value(step, key, new_value):
type_in_codemirror(get_index_of(key), new_value) type_in_codemirror(get_index_of(key), new_value)
world.wait(0.5)
press_the_notification_button(step, "Save") press_the_notification_button(step, "Save")
world.wait_for_ajax_complete()
...@@ -9,7 +9,8 @@ Feature: CMS.Course checklists ...@@ -9,7 +9,8 @@ Feature: CMS.Course checklists
Scenario: A course author can mark tasks as complete Scenario: A course author can mark tasks as complete
Given I have opened Checklists Given I have opened Checklists
Then I can check and uncheck tasks in a checklist Then I can check and uncheck tasks in a checklist
And They are correctly selected after reloading the page And I reload the page
Then the tasks are correctly selected
# There are issues getting link to be active in browsers other than chrome # There are issues getting link to be active in browsers other than chrome
@skip_firefox @skip_firefox
......
...@@ -45,11 +45,11 @@ def i_can_check_and_uncheck_tasks(step): ...@@ -45,11 +45,11 @@ def i_can_check_and_uncheck_tasks(step):
verifyChecklist2Status(2, 7, 29) verifyChecklist2Status(2, 7, 29)
@step('They are correctly selected after reloading the page$') @step('the tasks are correctly selected$')
def tasks_correctly_selected_after_reload(step): def tasks_correctly_selected(step):
reload_the_page(step)
verifyChecklist2Status(2, 7, 29) verifyChecklist2Status(2, 7, 29)
# verify that task 7 is still selected by toggling its checkbox state and making sure that it deselects # verify that task 7 is still selected by toggling its checkbox state and making sure that it deselects
world.browser.execute_script("window.scrollBy(0,1000)")
toggleTask(1, 6) toggleTask(1, 6)
verifyChecklist2Status(1, 7, 14) verifyChecklist2Status(1, 7, 14)
...@@ -109,13 +109,15 @@ def toggleTask(checklist, task): ...@@ -109,13 +109,15 @@ def toggleTask(checklist, task):
# TODO: figure out a way to do this in phantom and firefox # TODO: figure out a way to do this in phantom and firefox
# For now we will mark the scenerios that use this method as skipped # For now we will mark the scenerios that use this method as skipped
def clickActionLink(checklist, task, actionText): def clickActionLink(checklist, task, actionText):
# toggle checklist item to make sure that the link button is showing
toggleTask(checklist, task)
action_link = world.css_find('#course-checklist' + str(checklist) + ' a')[task]
# text will be empty initially, wait for it to populate # text will be empty initially, wait for it to populate
def verify_action_link_text(driver): def verify_action_link_text(driver):
return world.css_text('#course-checklist' + str(checklist) + ' a', index=task) == actionText actualText = world.css_text('#course-checklist' + str(checklist) + ' a', index=task)
if actualText == actionText:
return True
else:
# toggle checklist item to make sure that the link button is showing
toggleTask(checklist, task)
return False
world.wait_for(verify_action_link_text) world.wait_for(verify_action_link_text)
world.css_click('#course-checklist' + str(checklist) + ' a', index=task) world.css_click('#course-checklist' + str(checklist) + ' a', index=task)
...@@ -90,6 +90,7 @@ def press_the_notification_button(_step, name): ...@@ -90,6 +90,7 @@ def press_the_notification_button(_step, name):
world.browser.execute_script("$('{}').click()".format(btn_css)) world.browser.execute_script("$('{}').click()".format(btn_css))
else: else:
world.css_click(btn_css) world.css_click(btn_css)
world.wait_for_ajax_complete()
@step('I change the "(.*)" field to "(.*)"$') @step('I change the "(.*)" field to "(.*)"$')
...@@ -244,7 +245,9 @@ def open_new_unit(step): ...@@ -244,7 +245,9 @@ def open_new_unit(step):
step.given('I have opened a new course section in Studio') step.given('I have opened a new course section in Studio')
step.given('I have added a new subsection') step.given('I have added a new subsection')
step.given('I expand the first section') step.given('I expand the first section')
old_url = world.browser.url
world.css_click('a.new-unit-item') world.css_click('a.new-unit-item')
world.wait_for(lambda x: world.browser.url != old_url)
@step('the save notification button is disabled') @step('the save notification button is disabled')
...@@ -298,6 +301,7 @@ def type_in_codemirror(index, text): ...@@ -298,6 +301,7 @@ def type_in_codemirror(index, text):
g._element.send_keys(text) g._element.send_keys(text)
if world.is_firefox(): if world.is_firefox():
world.trigger_event('div.CodeMirror', index=index, event='blur') world.trigger_event('div.CodeMirror', index=index, event='blur')
world.wait_for_ajax_complete()
def upload_file(filename): def upload_file(filename):
......
...@@ -18,13 +18,22 @@ def add_unit(step): ...@@ -18,13 +18,22 @@ def add_unit(step):
user = create_studio_user(is_staff=False) user = create_studio_user(is_staff=False)
add_course_author(user, course) add_course_author(user, course)
log_into_studio() log_into_studio()
css_selectors = ['a.course-link', 'div.section-item a.expand-collapse-icon', 'a.new-unit-item'] world.wait_for_requirejs([
"jquery", "js/models/course", "coffee/src/models/module",
"coffee/src/views/unit", "jquery.ui",
])
world.wait_for_mathjax()
css_selectors = [
'a.course-link', 'div.section-item a.expand-collapse-icon',
'a.new-unit-item',
]
for selector in css_selectors: for selector in css_selectors:
world.css_click(selector) world.css_click(selector)
@step(u'I add this type of single step component:$') @step(u'I add this type of single step component:$')
def add_a_single_step_component(step): def add_a_single_step_component(step):
world.wait_for_xmodule()
for step_hash in step.hashes: for step_hash in step.hashes:
component = step_hash['Component'] component = step_hash['Component']
assert_in(component, ['Discussion', 'Video']) assert_in(component, ['Discussion', 'Video'])
...@@ -67,6 +76,7 @@ def add_a_multi_step_component(step, is_advanced, category): ...@@ -67,6 +76,7 @@ def add_a_multi_step_component(step, is_advanced, category):
def click_link(): def click_link():
link.click() link.click()
world.wait_for_xmodule()
category = category.lower() category = category.lower()
for step_hash in step.hashes: for step_hash in step.hashes:
css_selector = 'a[data-type="{}"]'.format(category) css_selector = 'a[data-type="{}"]'.format(category)
...@@ -103,7 +113,7 @@ def see_a_multi_step_component(step, category): ...@@ -103,7 +113,7 @@ def see_a_multi_step_component(step, category):
@step(u'I add a "([^"]*)" "([^"]*)" component$') @step(u'I add a "([^"]*)" "([^"]*)" component$')
def add_component_catetory(step, component, category): def add_component_category(step, component, category):
assert category in ('single step', 'HTML', 'Problem', 'Advanced Problem') assert category in ('single step', 'HTML', 'Problem', 'Advanced Problem')
given_string = 'I add this type of {} component:'.format(category) given_string = 'I add this type of {} component:'.format(category)
step.given('{}\n{}\n{}'.format(given_string, '|Component|', '|{}|'.format(component))) step.given('{}\n{}\n{}'.format(given_string, '|Component|', '|{}|'.format(component)))
...@@ -111,6 +121,7 @@ def add_component_catetory(step, component, category): ...@@ -111,6 +121,7 @@ def add_component_catetory(step, component, category):
@step(u'I delete all components$') @step(u'I delete all components$')
def delete_all_components(step): def delete_all_components(step):
world.wait_for_xmodule()
delete_btn_css = 'a.delete-button' delete_btn_css = 'a.delete-button'
prompt_css = 'div#prompt-warning' prompt_css = 'div#prompt-warning'
btn_css = '{} a.button.action-primary'.format(prompt_css) btn_css = '{} a.button.action-primary'.format(prompt_css)
...@@ -118,7 +129,8 @@ def delete_all_components(step): ...@@ -118,7 +129,8 @@ def delete_all_components(step):
count = len(world.css_find('ol.components li.component')) count = len(world.css_find('ol.components li.component'))
for _ in range(int(count)): for _ in range(int(count)):
world.css_click(delete_btn_css) world.css_click(delete_btn_css)
assert_true(world.is_css_present('{}.is-shown'.format(prompt_css)), assert_true(
world.is_css_present('{}.is-shown'.format(prompt_css)),
msg='Waiting for the confirmation prompt to be shown') msg='Waiting for the confirmation prompt to be shown')
# Pressing the button via css was not working reliably for the last component # Pressing the button via css was not working reliably for the last component
......
...@@ -20,16 +20,21 @@ def create_component_instance(step, component_button_css, category, ...@@ -20,16 +20,21 @@ def create_component_instance(step, component_button_css, category,
if has_multiple_templates: if has_multiple_templates:
click_component_from_menu(category, boilerplate, expected_css) click_component_from_menu(category, boilerplate, expected_css)
if category in ('video',):
world.wait_for_xmodule()
assert_equal( assert_equal(
1, 1,
len(world.css_find(expected_css)), len(world.css_find(expected_css)),
"Component instance with css {css} was not created successfully".format(css=expected_css)) "Component instance with css {css} was not created successfully".format(css=expected_css))
@world.absorb @world.absorb
def click_new_component_button(step, component_button_css): def click_new_component_button(step, component_button_css):
step.given('I have clicked the new unit button') step.given('I have clicked the new unit button')
world.wait_for_requirejs(
["jquery", "js/models/course", "coffee/src/models/module",
"coffee/src/views/unit", "jquery.ui"])
world.css_click(component_button_css) world.css_click(component_button_css)
...@@ -50,6 +55,7 @@ def click_component_from_menu(category, boilerplate, expected_css): ...@@ -50,6 +55,7 @@ def click_component_from_menu(category, boilerplate, expected_css):
assert_equal(len(elements), 1) assert_equal(len(elements), 1)
world.css_click(elem_css) world.css_click(elem_css)
@world.absorb @world.absorb
def edit_component_and_select_settings(): def edit_component_and_select_settings():
world.wait_for(lambda _driver: world.css_visible('a.edit-button')) world.wait_for(lambda _driver: world.css_visible('a.edit-button'))
...@@ -107,6 +113,7 @@ def verify_all_setting_entries(expected_entries): ...@@ -107,6 +113,7 @@ def verify_all_setting_entries(expected_entries):
@world.absorb @world.absorb
def save_component_and_reopen(step): def save_component_and_reopen(step):
world.css_click("a.save-button") world.css_click("a.save-button")
world.wait_for_ajax_complete()
# We have a known issue that modifications are still shown within the edit window after cancel (though) # We have a known issue that modifications are still shown within the edit window after cancel (though)
# they are not persisted. Refresh the browser to make sure the changes WERE persisted after Save. # they are not persisted. Refresh the browser to make sure the changes WERE persisted after Save.
reload_the_page(step) reload_the_page(step)
...@@ -136,6 +143,7 @@ def get_setting_entry(label): ...@@ -136,6 +143,7 @@ def get_setting_entry(label):
return None return None
return world.retry_on_exception(get_setting) return world.retry_on_exception(get_setting)
@world.absorb @world.absorb
def get_setting_entry_index(label): def get_setting_entry_index(label):
def get_index(): def get_index():
......
...@@ -9,7 +9,8 @@ Feature: CMS.Course Settings ...@@ -9,7 +9,8 @@ Feature: CMS.Course Settings
When I select Schedule and Details When I select Schedule and Details
And I set course dates And I set course dates
And I press the "Save" notification button And I press the "Save" notification button
Then I see the set dates on refresh And I reload the page
Then I see the set dates
# IE has trouble with saving information # IE has trouble with saving information
@skip_internetexplorer @skip_internetexplorer
...@@ -17,7 +18,8 @@ Feature: CMS.Course Settings ...@@ -17,7 +18,8 @@ Feature: CMS.Course Settings
Given I have set course dates Given I have set course dates
And I clear all the dates except start And I clear all the dates except start
And I press the "Save" notification button And I press the "Save" notification button
Then I see cleared dates on refresh And I reload the page
Then I see cleared dates
# IE has trouble with saving information # IE has trouble with saving information
@skip_internetexplorer @skip_internetexplorer
...@@ -26,7 +28,8 @@ Feature: CMS.Course Settings ...@@ -26,7 +28,8 @@ Feature: CMS.Course Settings
And I press the "Save" notification button And I press the "Save" notification button
And I clear the course start date And I clear the course start date
Then I receive a warning about course start date Then I receive a warning about course start date
And The previously set start date is shown on refresh And I reload the page
And the previously set start date is shown
# IE has trouble with saving information # IE has trouble with saving information
# Safari gets CSRF token errors # Safari gets CSRF token errors
...@@ -37,7 +40,8 @@ Feature: CMS.Course Settings ...@@ -37,7 +40,8 @@ Feature: CMS.Course Settings
And I have entered a new course start date And I have entered a new course start date
And I press the "Save" notification button And I press the "Save" notification button
Then The warning about course start date goes away Then The warning about course start date goes away
And My new course start date is shown on refresh And I reload the page
Then my new course start date is shown
# Safari does not save + refresh properly through sauce labs # Safari does not save + refresh properly through sauce labs
@skip_safari @skip_safari
...@@ -45,7 +49,8 @@ Feature: CMS.Course Settings ...@@ -45,7 +49,8 @@ Feature: CMS.Course Settings
Given I have set course dates Given I have set course dates
And I press the "Save" notification button And I press the "Save" notification button
When I change fields When I change fields
Then I do not see the new changes persisted on refresh And I reload the page
Then I do not see the changes
# Safari does not save + refresh properly through sauce labs # Safari does not save + refresh properly through sauce labs
@skip_safari @skip_safari
......
...@@ -31,6 +31,9 @@ def test_i_select_schedule_and_details(step): ...@@ -31,6 +31,9 @@ def test_i_select_schedule_and_details(step):
world.click_course_settings() world.click_course_settings()
link_css = 'li.nav-course-settings-schedule a' link_css = 'li.nav-course-settings-schedule a'
world.css_click(link_css) world.css_click(link_css)
world.wait_for_requirejs(
["jquery", "js/models/course",
"js/models/settings/course_details", "js/views/settings/main"])
@step('I have set course dates$') @step('I have set course dates$')
...@@ -51,12 +54,6 @@ def test_and_i_set_course_dates(step): ...@@ -51,12 +54,6 @@ def test_and_i_set_course_dates(step):
set_date_or_time(ENROLLMENT_END_TIME_CSS, DUMMY_TIME) set_date_or_time(ENROLLMENT_END_TIME_CSS, DUMMY_TIME)
@step('Then I see the set dates on refresh$')
def test_then_i_see_the_set_dates_on_refresh(step):
reload_the_page(step)
i_see_the_set_dates()
@step('And I clear all the dates except start$') @step('And I clear all the dates except start$')
def test_and_i_clear_all_the_dates_except_start(step): def test_and_i_clear_all_the_dates_except_start(step):
set_date_or_time(COURSE_END_DATE_CSS, '') set_date_or_time(COURSE_END_DATE_CSS, '')
...@@ -64,9 +61,8 @@ def test_and_i_clear_all_the_dates_except_start(step): ...@@ -64,9 +61,8 @@ def test_and_i_clear_all_the_dates_except_start(step):
set_date_or_time(ENROLLMENT_END_DATE_CSS, '') set_date_or_time(ENROLLMENT_END_DATE_CSS, '')
@step('Then I see cleared dates on refresh$') @step('Then I see cleared dates$')
def test_then_i_see_cleared_dates_on_refresh(step): def test_then_i_see_cleared_dates(step):
reload_the_page(step)
verify_date_or_time(COURSE_END_DATE_CSS, '') verify_date_or_time(COURSE_END_DATE_CSS, '')
verify_date_or_time(ENROLLMENT_START_DATE_CSS, '') verify_date_or_time(ENROLLMENT_START_DATE_CSS, '')
verify_date_or_time(ENROLLMENT_END_DATE_CSS, '') verify_date_or_time(ENROLLMENT_END_DATE_CSS, '')
...@@ -92,9 +88,8 @@ def test_i_receive_a_warning_about_course_start_date(step): ...@@ -92,9 +88,8 @@ def test_i_receive_a_warning_about_course_start_date(step):
assert_true('error' in world.css_find(COURSE_START_TIME_CSS).first._element.get_attribute('class')) assert_true('error' in world.css_find(COURSE_START_TIME_CSS).first._element.get_attribute('class'))
@step('The previously set start date is shown on refresh$') @step('the previously set start date is shown$')
def test_the_previously_set_start_date_is_shown_on_refresh(step): def test_the_previously_set_start_date_is_shown(step):
reload_the_page(step)
verify_date_or_time(COURSE_START_DATE_CSS, '12/20/2013') verify_date_or_time(COURSE_START_DATE_CSS, '12/20/2013')
verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME) verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
...@@ -118,9 +113,8 @@ def test_the_warning_about_course_start_date_goes_away(step): ...@@ -118,9 +113,8 @@ def test_the_warning_about_course_start_date_goes_away(step):
assert_false('error' in world.css_find(COURSE_START_TIME_CSS).first._element.get_attribute('class')) assert_false('error' in world.css_find(COURSE_START_TIME_CSS).first._element.get_attribute('class'))
@step('My new course start date is shown on refresh$') @step('my new course start date is shown$')
def test_my_new_course_start_date_is_shown_on_refresh(step): def new_course_start_date_is_shown(step):
reload_the_page(step)
verify_date_or_time(COURSE_START_DATE_CSS, '12/22/2013') verify_date_or_time(COURSE_START_DATE_CSS, '12/22/2013')
# Time should have stayed from before attempt to clear date. # Time should have stayed from before attempt to clear date.
verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME) verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
...@@ -134,16 +128,6 @@ def test_i_change_fields(step): ...@@ -134,16 +128,6 @@ def test_i_change_fields(step):
set_date_or_time(ENROLLMENT_END_DATE_CSS, '7/7/7777') set_date_or_time(ENROLLMENT_END_DATE_CSS, '7/7/7777')
@step('I do not see the new changes persisted on refresh$')
def test_changes_not_shown_on_refresh(step):
step.then('Then I see the set dates on refresh')
@step('I do not see the changes')
def test_i_do_not_see_changes(_step):
i_see_the_set_dates()
@step('I change the course overview') @step('I change the course overview')
def test_change_course_overview(_step): def test_change_course_overview(_step):
type_in_codemirror(0, "<h1>Overview</h1>") type_in_codemirror(0, "<h1>Overview</h1>")
...@@ -168,11 +152,8 @@ def i_see_new_course_image(_step): ...@@ -168,11 +152,8 @@ def i_see_new_course_image(_step):
img = images[0] img = images[0]
expected_src = '/c4x/MITx/999/asset/image.jpg' expected_src = '/c4x/MITx/999/asset/image.jpg'
# Don't worry about the domain in the URL # Don't worry about the domain in the URL
try: assert img['src'].endswith(expected_src), "Was looking for {expected}, found {actual}".format(
assert img['src'].endswith(expected_src) expected=expected_src, actual=img['src'])
except AssertionError as e:
e.args += ('Was looking for {}'.format(expected_src), 'Found {}'.format(img['src']))
raise
@step('the image URL should be present in the field') @step('the image URL should be present in the field')
...@@ -200,7 +181,9 @@ def verify_date_or_time(css, date_or_time): ...@@ -200,7 +181,9 @@ def verify_date_or_time(css, date_or_time):
assert_equal(date_or_time, world.css_value(css)) assert_equal(date_or_time, world.css_value(css))
def i_see_the_set_dates(): @step('I do not see the changes')
@step('I see the set dates')
def i_see_the_set_dates(_step):
""" """
Ensure that each field has the value set in `test_and_i_set_course_dates`. Ensure that each field has the value set in `test_and_i_set_course_dates`.
""" """
......
...@@ -4,7 +4,8 @@ ...@@ -4,7 +4,8 @@
from lettuce import world, step from lettuce import world, step
from common import * from common import *
from terrain.steps import reload_the_page from terrain.steps import reload_the_page
from selenium.common.exceptions import InvalidElementStateException from selenium.common.exceptions import (
InvalidElementStateException, WebDriverException)
from nose.tools import assert_in, assert_not_in # pylint: disable=E0611 from nose.tools import assert_in, assert_not_in # pylint: disable=E0611
...@@ -134,7 +135,7 @@ def change_grade_range(_step, range_name): ...@@ -134,7 +135,7 @@ def change_grade_range(_step, range_name):
def i_see_highest_grade_range(_step, range_name): def i_see_highest_grade_range(_step, range_name):
range_css = 'span.letter-grade' range_css = 'span.letter-grade'
grade = world.css_find(range_css).first grade = world.css_find(range_css).first
assert grade.value == range_name assert grade.value == range_name, "{0} != {1}".format(grade.value, range_name)
@step(u'I cannot edit the "Fail" grade range$') @step(u'I cannot edit the "Fail" grade range$')
...@@ -142,12 +143,18 @@ def cannot_edit_fail(_step): ...@@ -142,12 +143,18 @@ def cannot_edit_fail(_step):
range_css = 'span.letter-grade' range_css = 'span.letter-grade'
ranges = world.css_find(range_css) ranges = world.css_find(range_css)
assert len(ranges) == 2 assert len(ranges) == 2
assert ranges.last.value != 'Failure'
# try to change the grade range -- this should throw an exception
try: try:
ranges.last.value = 'Failure' ranges.last.value = 'Failure'
assert False, "Should not be able to edit failing range" except (InvalidElementStateException):
except InvalidElementStateException:
pass # We should get this exception on failing to edit the element pass # We should get this exception on failing to edit the element
# check to be sure that nothing has changed
ranges = world.css_find(range_css)
assert len(ranges) == 2
assert ranges.last.value != 'Failure'
@step(u'I change the grace period to "(.*)"$') @step(u'I change the grace period to "(.*)"$')
......
...@@ -142,8 +142,9 @@ def set_the_max_attempts(step, max_attempts_set): ...@@ -142,8 +142,9 @@ def set_the_max_attempts(step, max_attempts_set):
if world.is_firefox(): if world.is_firefox():
world.trigger_event('.wrapper-comp-setting .setting-input', index=index) world.trigger_event('.wrapper-comp-setting .setting-input', index=index)
world.save_component_and_reopen(step) world.save_component_and_reopen(step)
value = int(world.css_value('input.setting-input', index=index)) value = world.css_value('input.setting-input', index=index)
assert value >= 0 assert value != "", "max attempts is blank"
assert int(value) >= 0
@step('Edit High Level Source is not visible') @step('Edit High Level Source is not visible')
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
from lettuce import world, step from lettuce import world, step
from django.conf import settings from django.conf import settings
from common import upload_file from common import upload_file
from nose.tools import assert_equal
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
...@@ -82,20 +83,23 @@ def save_textbook(_step): ...@@ -82,20 +83,23 @@ def save_textbook(_step):
@step(u'I should see a textbook named "([^"]*)" with a chapter path containing "([^"]*)"') @step(u'I should see a textbook named "([^"]*)" with a chapter path containing "([^"]*)"')
def check_textbook(_step, textbook_name, chapter_name): def check_textbook(_step, textbook_name, chapter_name):
title = world.css_find(".textbook h3.textbook-title") title = world.css_text(".textbook h3.textbook-title", index=0)
chapter = world.css_find(".textbook .wrap-textbook p") chapter = world.css_text(".textbook .wrap-textbook p", index=0)
assert title.text == textbook_name, "{} != {}".format(title.text, textbook_name) assert_equal(title, textbook_name)
assert chapter.text == chapter_name, "{} != {}".format(chapter.text, chapter_name) assert_equal(chapter, chapter_name)
@step(u'I should see a textbook named "([^"]*)" with (\d+) chapters') @step(u'I should see a textbook named "([^"]*)" with (\d+) chapters')
def check_textbook_chapters(_step, textbook_name, num_chapters_str): def check_textbook_chapters(_step, textbook_name, num_chapters_str):
num_chapters = int(num_chapters_str) num_chapters = int(num_chapters_str)
title = world.css_find(".textbook .view-textbook h3.textbook-title") title = world.css_text(".textbook .view-textbook h3.textbook-title", index=0)
toggle = world.css_find(".textbook .view-textbook .chapter-toggle") toggle_text = world.css_text(".textbook .view-textbook .chapter-toggle", index=0)
assert title.text == textbook_name, "{} != {}".format(title.text, textbook_name) assert_equal(title, textbook_name)
assert toggle.text == "{num} PDF Chapters".format(num=num_chapters), \ assert_equal(
"Expected {num} chapters, found {real}".format(num=num_chapters, real=toggle.text) toggle_text,
"{num} PDF Chapters".format(num=num_chapters),
"Expected {num} chapters, found {real}".format(num=num_chapters, real=toggle_text)
)
@step(u'I click the textbook chapters') @step(u'I click the textbook chapters')
......
...@@ -10,7 +10,7 @@ import random ...@@ -10,7 +10,7 @@ import random
import os import os
from django.contrib.auth.models import User from django.contrib.auth.models import User
from student.models import CourseEnrollment from student.models import CourseEnrollment
from nose.tools import assert_equal, assert_not_equal # pylint: disable=E0611 from nose.tools import assert_equal, assert_not_equal # pylint: disable=E0611
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
ASSET_NAMES_CSS = 'td.name-col > span.title > a.filename' ASSET_NAMES_CSS = 'td.name-col > span.title > a.filename'
...@@ -79,7 +79,7 @@ def check_upload(_step, file_name): ...@@ -79,7 +79,7 @@ def check_upload(_step, file_name):
@step(u'The url for the file "([^"]*)" is valid$') @step(u'The url for the file "([^"]*)" is valid$')
def check_url(_step, file_name): def check_url(_step, file_name):
r = get_file(file_name) r = get_file(file_name)
assert_equal(r.status_code , 200) assert_equal(r.status_code, 200)
@step(u'I delete the file "([^"]*)"$') @step(u'I delete the file "([^"]*)"$')
...@@ -89,6 +89,8 @@ def delete_file(_step, file_name): ...@@ -89,6 +89,8 @@ def delete_file(_step, file_name):
delete_css = "a.remove-asset-button" delete_css = "a.remove-asset-button"
world.css_click(delete_css, index=index) world.css_click(delete_css, index=index)
world.wait_for_present(".wrapper-prompt.is-shown")
world.wait(0.2) # wait for css animation
prompt_confirm_css = 'li.nav-item > a.action-primary' prompt_confirm_css = 'li.nav-item > a.action-primary'
world.css_click(prompt_confirm_css) world.css_click(prompt_confirm_css)
......
...@@ -18,6 +18,8 @@ def set_show_captions(step, setting): ...@@ -18,6 +18,8 @@ def set_show_captions(step, setting):
@step('when I view the video it (.*) show the captions$') @step('when I view the video it (.*) show the captions$')
def shows_captions(_step, show_captions): def shows_captions(_step, show_captions):
world.wait_for_js_variable_truthy("Video")
world.wait(0.5)
if show_captions == 'does not': if show_captions == 'does not':
assert world.is_css_present('div.video.closed') assert world.is_css_present('div.video.closed')
else: else:
...@@ -48,6 +50,6 @@ def correct_video_settings(_step): ...@@ -48,6 +50,6 @@ def correct_video_settings(_step):
def video_name_persisted(step): def video_name_persisted(step):
world.css_click('a.save-button') world.css_click('a.save-button')
reload_the_page(step) reload_the_page(step)
world.wait_for_xmodule()
world.edit_component() world.edit_component()
world.verify_setting_entry(world.get_setting_entry('Display Name'), 'Display Name', '3.4', True) world.verify_setting_entry(world.get_setting_entry('Display Name'), 'Display Name', '3.4', True)
...@@ -33,4 +33,5 @@ Feature: CMS.Video Component ...@@ -33,4 +33,5 @@ Feature: CMS.Video Component
Scenario: Video data is shown correctly Scenario: Video data is shown correctly
Given I have created a video with only XML data Given I have created a video with only XML data
And I reload the page
Then the correct Youtube video is shown Then the correct Youtube video is shown
#pylint: disable=C0111 #pylint: disable=C0111
from lettuce import world, step from lettuce import world, step
from terrain.steps import reload_the_page
from xmodule.modulestore import Location from xmodule.modulestore import Location
from contentstore.utils import get_modulestore from contentstore.utils import get_modulestore
...@@ -32,6 +31,7 @@ def i_created_a_video_with_subs_with_name(_step, sub_id): ...@@ -32,6 +31,7 @@ def i_created_a_video_with_subs_with_name(_step, sub_id):
# Return to the video # Return to the video
world.visit(video_url) world.visit(video_url)
world.wait_for_xmodule()
@step('I have uploaded subtitles "([^"]*)"$') @step('I have uploaded subtitles "([^"]*)"$')
...@@ -46,6 +46,7 @@ def i_have_uploaded_subtitles(_step, sub_id): ...@@ -46,6 +46,7 @@ def i_have_uploaded_subtitles(_step, sub_id):
@step('when I view the (.*) it does not have autoplay enabled$') @step('when I view the (.*) it does not have autoplay enabled$')
def does_not_autoplay(_step, video_type): def does_not_autoplay(_step, video_type):
world.wait_for_xmodule()
assert world.css_find('.%s' % video_type)[0]['data-autoplay'] == 'False' assert world.css_find('.%s' % video_type)[0]['data-autoplay'] == 'False'
assert world.css_has_class('.video_control', 'play') assert world.css_has_class('.video_control', 'play')
...@@ -66,6 +67,7 @@ def i_edit_the_component(_step): ...@@ -66,6 +67,7 @@ def i_edit_the_component(_step):
@step('I have (hidden|toggled) captions$') @step('I have (hidden|toggled) captions$')
def hide_or_show_captions(step, shown): def hide_or_show_captions(step, shown):
world.wait_for_xmodule()
button_css = 'a.hide-subtitles' button_css = 'a.hide-subtitles'
if shown == 'hidden': if shown == 'hidden':
world.css_click(button_css) world.css_click(button_css)
...@@ -107,12 +109,9 @@ def xml_only_video(step): ...@@ -107,12 +109,9 @@ def xml_only_video(step):
data='<video youtube="1.00:%s"></video>' % youtube_id data='<video youtube="1.00:%s"></video>' % youtube_id
) )
# Refresh to see the new video
reload_the_page(step)
@step('The correct Youtube video is shown$') @step('The correct Youtube video is shown$')
def the_youtube_video_is_shown(_step): def the_youtube_video_is_shown(_step):
world.wait_for_xmodule()
ele = world.css_find('.video').first ele = world.css_find('.video').first
assert ele['data-streams'].split(':')[1] == world.scenario_dict['YOUTUBE_ID'] assert ele['data-streams'].split(':')[1] == world.scenario_dict['YOUTUBE_ID']
...@@ -20,6 +20,7 @@ from xmodule.modulestore.django import modulestore ...@@ -20,6 +20,7 @@ from xmodule.modulestore.django import modulestore
from xmodule.modulestore.xml_importer import import_from_xml from xmodule.modulestore.xml_importer import import_from_xml
import json import json
class AssetsTestCase(CourseTestCase): class AssetsTestCase(CourseTestCase):
def setUp(self): def setUp(self):
super(AssetsTestCase, self).setUp() super(AssetsTestCase, self).setUp()
...@@ -50,7 +51,7 @@ class AssetsToyCourseTestCase(CourseTestCase): ...@@ -50,7 +51,7 @@ class AssetsToyCourseTestCase(CourseTestCase):
resp = self.client.get(url) resp = self.client.get(url)
# Test a small portion of the asset data passed to the client. # Test a small portion of the asset data passed to the client.
self.assertContains(resp, "new CMS.Models.AssetCollection([{") self.assertContains(resp, "new AssetCollection([{")
self.assertContains(resp, "/c4x/edX/toy/asset/handouts_sample_handout.txt") self.assertContains(resp, "/c4x/edX/toy/asset/handouts_sample_handout.txt")
......
...@@ -90,7 +90,10 @@ def save_item(request): ...@@ -90,7 +90,10 @@ def save_item(request):
if value is None: if value is None:
field.delete_from(existing_item) field.delete_from(existing_item)
else: else:
value = field.from_json(value) try:
value = field.from_json(value)
except ValueError:
return JsonResponse({"error": "Invalid data"}, 400)
field.write_to(existing_item, value) field.write_to(existing_item, value)
# Save the data that we've just changed to the underlying # Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore. # MongoKeyValueStore before we update the mongo datastore.
......
...@@ -108,6 +108,7 @@ def preview_module_system(request, preview_id, descriptor): ...@@ -108,6 +108,7 @@ def preview_module_system(request, preview_id, descriptor):
wrapper_template = 'xmodule_display.html' wrapper_template = 'xmodule_display.html'
return ModuleSystem( return ModuleSystem(
static_url=settings.STATIC_URL,
ajax_url=reverse('preview_dispatch', args=[preview_id, descriptor.location.url(), '']).rstrip('/'), ajax_url=reverse('preview_dispatch', args=[preview_id, descriptor.location.url(), '']).rstrip('/'),
# TODO (cpennington): Do we want to track how instructors are using the preview problems? # TODO (cpennington): Do we want to track how instructors are using the preview problems?
track_function=lambda event_type, event: None, track_function=lambda event_type, event: None,
......
...@@ -32,6 +32,7 @@ from lms.xblock.mixin import LmsBlockMixin ...@@ -32,6 +32,7 @@ from lms.xblock.mixin import LmsBlockMixin
from cms.xmodule_namespace import CmsBlockMixin from cms.xmodule_namespace import CmsBlockMixin
from xmodule.modulestore.inheritance import InheritanceMixin from xmodule.modulestore.inheritance import InheritanceMixin
from xmodule.x_module import XModuleMixin from xmodule.x_module import XModuleMixin
from dealer.git import git
############################ FEATURE CONFIGURATION ############################# ############################ FEATURE CONFIGURATION #############################
...@@ -69,6 +70,7 @@ ENABLE_JASMINE = False ...@@ -69,6 +70,7 @@ ENABLE_JASMINE = False
PROJECT_ROOT = path(__file__).abspath().dirname().dirname() # /mitx/cms PROJECT_ROOT = path(__file__).abspath().dirname().dirname() # /mitx/cms
REPO_ROOT = PROJECT_ROOT.dirname() REPO_ROOT = PROJECT_ROOT.dirname()
COMMON_ROOT = REPO_ROOT / "common" COMMON_ROOT = REPO_ROOT / "common"
LMS_ROOT = REPO_ROOT / "lms"
ENV_ROOT = REPO_ROOT.dirname() # virtualenv dir /mitx is in ENV_ROOT = REPO_ROOT.dirname() # virtualenv dir /mitx is in
GITHUB_REPO_ROOT = ENV_ROOT / "data" GITHUB_REPO_ROOT = ENV_ROOT / "data"
...@@ -88,7 +90,8 @@ MAKO_TEMPLATES = {} ...@@ -88,7 +90,8 @@ MAKO_TEMPLATES = {}
MAKO_TEMPLATES['main'] = [ MAKO_TEMPLATES['main'] = [
PROJECT_ROOT / 'templates', PROJECT_ROOT / 'templates',
COMMON_ROOT / 'templates', COMMON_ROOT / 'templates',
COMMON_ROOT / 'djangoapps' / 'pipeline_mako' / 'templates' COMMON_ROOT / 'djangoapps' / 'pipeline_mako' / 'templates',
COMMON_ROOT / 'djangoapps' / 'pipeline_js' / 'templates',
] ]
for namespace, template_dirs in lms.envs.common.MAKO_TEMPLATES.iteritems(): for namespace, template_dirs in lms.envs.common.MAKO_TEMPLATES.iteritems():
...@@ -107,7 +110,8 @@ TEMPLATE_CONTEXT_PROCESSORS = ( ...@@ -107,7 +110,8 @@ TEMPLATE_CONTEXT_PROCESSORS = (
'django.core.context_processors.static', 'django.core.context_processors.static',
'django.contrib.messages.context_processors.messages', 'django.contrib.messages.context_processors.messages',
'django.contrib.auth.context_processors.auth', # this is required for admin 'django.contrib.auth.context_processors.auth', # this is required for admin
'django.core.context_processors.csrf' 'django.core.context_processors.csrf',
'dealer.contrib.django.staff.context_processor', # access git revision
) )
# use the ratelimit backend to prevent brute force attacks # use the ratelimit backend to prevent brute force attacks
...@@ -197,13 +201,14 @@ ADMINS = () ...@@ -197,13 +201,14 @@ ADMINS = ()
MANAGERS = ADMINS MANAGERS = ADMINS
# Static content # Static content
STATIC_URL = '/static/' STATIC_URL = '/static/' + git.revision + "/"
ADMIN_MEDIA_PREFIX = '/static/admin/' ADMIN_MEDIA_PREFIX = '/static/admin/'
STATIC_ROOT = ENV_ROOT / "staticfiles" STATIC_ROOT = ENV_ROOT / "staticfiles" / git.revision
STATICFILES_DIRS = [ STATICFILES_DIRS = [
COMMON_ROOT / "static", COMMON_ROOT / "static",
PROJECT_ROOT / "static", PROJECT_ROOT / "static",
LMS_ROOT / "static",
# This is how you would use the textbook images locally # This is how you would use the textbook images locally
# ("book", ENV_ROOT / "book_images") # ("book", ENV_ROOT / "book_images")
...@@ -245,42 +250,39 @@ PIPELINE_CSS = { ...@@ -245,42 +250,39 @@ PIPELINE_CSS = {
# test_order: Determines the position of this chunk of javascript on # test_order: Determines the position of this chunk of javascript on
# the jasmine test page # the jasmine test page
PIPELINE_JS = { PIPELINE_JS = {
'main': {
'source_filenames': sorted(
rooted_glob(COMMON_ROOT / 'static/', 'coffee/src/**/*.js') +
rooted_glob(PROJECT_ROOT / 'static/', 'coffee/src/**/*.js')
) + ['js/hesitate.js', 'js/base.js', 'js/views/feedback.js',
'js/models/course.js',
'js/models/section.js', 'js/views/section.js',
'js/models/metadata_model.js', 'js/views/metadata_editor_view.js',
'js/models/uploads.js', 'js/views/uploads.js',
'js/models/textbook.js', 'js/views/textbook.js',
'js/src/utility.js',
'js/models/settings/course_grading_policy.js',
'js/models/asset.js', 'js/models/assets.js',
'js/views/assets.js',
'js/views/assets_view.js', 'js/views/asset_view.js'],
'output_filename': 'js/cms-application.js',
'test_order': 0
},
'module-js': { 'module-js': {
'source_filenames': ( 'source_filenames': (
rooted_glob(COMMON_ROOT / 'static/', 'xmodule/descriptors/js/*.js') + rooted_glob(COMMON_ROOT / 'static/', 'xmodule/descriptors/js/*.js') +
rooted_glob(COMMON_ROOT / 'static/', 'xmodule/modules/js/*.js') rooted_glob(COMMON_ROOT / 'static/', 'xmodule/modules/js/*.js') +
rooted_glob(COMMON_ROOT / 'static/', 'coffee/src/discussion/*.js')
), ),
'output_filename': 'js/cms-modules.js', 'output_filename': 'js/cms-modules.js',
'test_order': 1 'test_order': 1
}, },
} }
PIPELINE_COMPILERS = (
'pipeline.compilers.coffee.CoffeeScriptCompiler',
)
PIPELINE_CSS_COMPRESSOR = None PIPELINE_CSS_COMPRESSOR = None
PIPELINE_JS_COMPRESSOR = None PIPELINE_JS_COMPRESSOR = None
STATICFILES_IGNORE_PATTERNS = ( STATICFILES_IGNORE_PATTERNS = (
"sass/*",
"coffee/*",
"*.py", "*.py",
"*.pyc" "*.pyc"
# it would be nice if we could do, for example, "**/*.scss",
# but these strings get passed down to the `fnmatch` module,
# which doesn't support that. :(
# http://docs.python.org/2/library/fnmatch.html
"sass/*.scss",
"sass/*/*.scss",
"sass/*/*/*.scss",
"sass/*/*/*/*.scss",
"coffee/*.coffee",
"coffee/*/*.coffee",
"coffee/*/*/*.coffee",
"coffee/*/*/*/*.coffee",
) )
PIPELINE_YUI_BINARY = 'yui-compressor' PIPELINE_YUI_BINARY = 'yui-compressor'
......
jasmine.getFixtures().fixturesPath += 'coffee/fixtures'
# Stub jQuery.cookie
@stubCookies =
csrftoken: "stubCSRFToken"
jQuery.cookie = (key, value) =>
if value?
@stubCookies[key] = value
else
@stubCookies[key]
# Path Jasmine's `it` method to raise an error when the test is not defined.
# This is helpful when writing the specs first before writing the test.
@it = (desc, func) ->
if func?
jasmine.getEnv().it(desc, func)
else
jasmine.getEnv().it desc, ->
throw "test is undefined"
requirejs.config({
paths: {
"gettext": "xmodule_js/common_static/js/test/i18n",
"mustache": "xmodule_js/common_static/js/vendor/mustache",
"codemirror": "xmodule_js/common_static/js/vendor/CodeMirror/codemirror",
"jquery": "xmodule_js/common_static/js/vendor/jquery.min",
"jquery.ui": "xmodule_js/common_static/js/vendor/jquery-ui.min",
"jquery.form": "xmodule_js/common_static/js/vendor/jquery.form",
"jquery.markitup": "xmodule_js/common_static/js/vendor/markitup/jquery.markitup",
"jquery.leanModal": "xmodule_js/common_static/js/vendor/jquery.leanModal.min",
"jquery.smoothScroll": "xmodule_js/common_static/js/vendor/jquery.smooth-scroll.min",
"jquery.scrollTo": "xmodule_js/common_static/js/vendor/jquery.scrollTo-1.4.2-min",
"jquery.timepicker": "xmodule_js/common_static/js/vendor/timepicker/jquery.timepicker",
"jquery.cookie": "xmodule_js/common_static/js/vendor/jquery.cookie",
"jquery.qtip": "xmodule_js/common_static/js/vendor/jquery.qtip.min",
"jquery.fileupload": "xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload",
"jquery.iframe-transport": "xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport",
"jquery.inputnumber": "xmodule_js/common_static/js/vendor/html5-input-polyfills/number-polyfill",
"datepair": "xmodule_js/common_static/js/vendor/timepicker/datepair",
"date": "xmodule_js/common_static/js/vendor/date",
"underscore": "xmodule_js/common_static/js/vendor/underscore-min",
"underscore.string": "xmodule_js/common_static/js/vendor/underscore.string.min",
"backbone": "xmodule_js/common_static/js/vendor/backbone-min",
"backbone.associations": "xmodule_js/common_static/js/vendor/backbone-associations-min",
"youtube": "xmodule_js/common_static/js/load_youtube",
"tinymce": "xmodule_js/common_static/js/vendor/tiny_mce/tiny_mce",
"jquery.tinymce": "xmodule_js/common_static/js/vendor/tiny_mce/jquery.tinymce",
"mathjax": "https://edx-static.s3.amazonaws.com/mathjax-MathJax-727332c/MathJax.js?config=TeX-MML-AM_HTMLorMML-full",
"xmodule": "xmodule_js/src/xmodule",
"utility": "xmodule_js/common_static/js/src/utility",
"sinon": "xmodule_js/common_static/js/vendor/sinon-1.7.1",
"squire": "xmodule_js/common_static/js/vendor/Squire",
"jasmine-stealth": "xmodule_js/common_static/js/vendor/jasmine-stealth",
"jasmine.async": "xmodule_js/common_static/js/vendor/jasmine.async",
"coffee/src/ajax_prefix": "xmodule_js/common_static/coffee/src/ajax_prefix"
},
shim: {
"gettext": {
exports: "gettext"
},
"date": {
exports: "Date"
},
"jquery.ui": {
deps: ["jquery"],
exports: "jQuery.ui"
},
"jquery.form": {
deps: ["jquery"],
exports: "jQuery.fn.ajaxForm"
},
"jquery.markitup": {
deps: ["jquery"],
exports: "jQuery.fn.markitup"
},
"jquery.leanModal": {
deps: ["jquery"],
exports: "jQuery.fn.leanModal"
},
"jquery.smoothScroll": {
deps: ["jquery"],
exports: "jQuery.fn.smoothScroll"
},
"jquery.scrollTo": {
deps: ["jquery"],
exports: "jQuery.fn.scrollTo"
},
"jquery.cookie": {
deps: ["jquery"],
exports: "jQuery.fn.cookie"
},
"jquery.qtip": {
deps: ["jquery"],
exports: "jQuery.fn.qtip"
},
"jquery.fileupload": {
deps: ["jquery.iframe-transport"],
exports: "jQuery.fn.fileupload"
},
"jquery.inputnumber": {
deps: ["jquery"],
exports: "jQuery.fn.inputNumber"
},
"jquery.tinymce": {
deps: ["jquery", "tinymce"],
exports: "jQuery.fn.tinymce"
},
"datepair": {
deps: ["jquery.ui", "jquery.timepicker"]
},
"underscore": {
exports: "_"
},
"backbone": {
deps: ["underscore", "jquery"],
exports: "Backbone"
},
"backbone.associations": {
deps: ["backbone"],
exports: "Backbone.Associations"
},
"codemirror": {
exports: "CodeMirror"
},
"tinymce": {
exports: "tinymce"
},
"mathjax": {
exports: "MathJax"
},
"xmodule": {
exports: "XModule"
},
"sinon": {
exports: "sinon"
},
"jasmine-stealth": {
deps: ["jasmine"]
},
"jasmine.async": {
deps: ["jasmine"],
exports: "AsyncSpec"
},
"coffee/src/main": {
deps: ["coffee/src/ajax_prefix"]
},
"coffee/src/ajax_prefix": {
deps: ["jquery"]
}
}
});
jasmine.getFixtures().fixturesPath += 'coffee/fixtures'
define([
"coffee/spec/main_spec",
"coffee/spec/models/course_spec", "coffee/spec/models/metadata_spec",
"coffee/spec/models/module_spec", "coffee/spec/models/section_spec",
"coffee/spec/models/settings_grading_spec", "coffee/spec/models/textbook_spec",
"coffee/spec/models/upload_spec",
"coffee/spec/views/section_spec",
"coffee/spec/views/course_info_spec", "coffee/spec/views/feedback_spec",
"coffee/spec/views/metadata_edit_spec", "coffee/spec/views/module_edit_spec",
"coffee/spec/views/textbook_spec", "coffee/spec/views/upload_spec",
# these tests are run separate in the cms-squire suite, due to process
# isolation issues with Squire.js
# "coffee/spec/views/assets_spec"
])
describe "CMS", -> require ["jquery", "backbone", "coffee/src/main", "sinon", "jasmine-stealth"],
beforeEach -> ($, Backbone, main, sinon) ->
CMS.unbind() describe "CMS", ->
it "should initialize URL", ->
it "should initialize Models", -> expect(window.CMS.URL).toBeDefined()
expect(CMS.Models).toBeDefined()
describe "main helper", ->
it "should initialize Views", -> beforeEach ->
expect(CMS.Views).toBeDefined() @previousAjaxSettings = $.extend(true, {}, $.ajaxSettings)
spyOn($, "cookie")
describe "main helper", -> $.cookie.when("csrftoken").thenReturn("stubCSRFToken")
beforeEach -> main()
@previousAjaxSettings = $.extend(true, {}, $.ajaxSettings)
window.stubCookies["csrftoken"] = "stubCSRFToken" afterEach ->
$(document).ready() $.ajaxSettings = @previousAjaxSettings
afterEach -> it "turn on Backbone emulateHTTP", ->
$.ajaxSettings = @previousAjaxSettings expect(Backbone.emulateHTTP).toBeTruthy()
it "turn on Backbone emulateHTTP", -> it "setup AJAX CSRF token", ->
expect(Backbone.emulateHTTP).toBeTruthy() expect($.ajaxSettings.headers["X-CSRFToken"]).toEqual("stubCSRFToken")
it "setup AJAX CSRF token", -> describe "AJAX Errors", ->
expect($.ajaxSettings.headers["X-CSRFToken"]).toEqual("stubCSRFToken") tpl = readFixtures('system-feedback.underscore')
describe "AJAX Errors", -> beforeEach ->
tpl = readFixtures('system-feedback.underscore') setFixtures($("<script>", {id: "system-feedback-tpl", type: "text/template"}).text(tpl))
appendSetFixtures(sandbox({id: "page-notification"}))
beforeEach -> @requests = requests = []
setFixtures($("<script>", {id: "system-feedback-tpl", type: "text/template"}).text(tpl)) @xhr = sinon.useFakeXMLHttpRequest()
appendSetFixtures(sandbox({id: "page-notification"})) @xhr.onCreate = (xhr) -> requests.push(xhr)
@requests = requests = []
@xhr = sinon.useFakeXMLHttpRequest() afterEach ->
@xhr.onCreate = (xhr) -> requests.push(xhr) @xhr.restore()
afterEach -> it "successful AJAX request does not pop an error notification", ->
@xhr.restore() expect($("#page-notification")).toBeEmpty()
$.ajax("/test")
it "successful AJAX request does not pop an error notification", -> expect($("#page-notification")).toBeEmpty()
expect($("#page-notification")).toBeEmpty() @requests[0].respond(200)
$.ajax("/test") expect($("#page-notification")).toBeEmpty()
expect($("#page-notification")).toBeEmpty()
@requests[0].respond(200) it "AJAX request with error should pop an error notification", ->
expect($("#page-notification")).toBeEmpty() $.ajax("/test")
@requests[0].respond(500)
it "AJAX request with error should pop an error notification", -> expect($("#page-notification")).not.toBeEmpty()
$.ajax("/test") expect($("#page-notification")).toContain('div.wrapper-notification-error')
@requests[0].respond(500)
expect($("#page-notification")).not.toBeEmpty() it "can override AJAX request with error so it does not pop an error notification", ->
expect($("#page-notification")).toContain('div.wrapper-notification-error') $.ajax
url: "/test"
it "can override AJAX request with error so it does not pop an error notification", -> notifyOnError: false
$.ajax @requests[0].respond(500)
url: "/test" expect($("#page-notification")).toBeEmpty()
notifyOnError: false
@requests[0].respond(500)
expect($("#page-notification")).toBeEmpty()
requirejs.config({
paths: {
"gettext": "xmodule_js/common_static/js/test/i18n",
"mustache": "xmodule_js/common_static/js/vendor/mustache",
"codemirror": "xmodule_js/common_static/js/vendor/CodeMirror/codemirror",
"jquery": "xmodule_js/common_static/js/vendor/jquery.min",
"jquery.ui": "xmodule_js/common_static/js/vendor/jquery-ui.min",
"jquery.form": "xmodule_js/common_static/js/vendor/jquery.form",
"jquery.markitup": "xmodule_js/common_static/js/vendor/markitup/jquery.markitup",
"jquery.leanModal": "xmodule_js/common_static/js/vendor/jquery.leanModal.min",
"jquery.smoothScroll": "xmodule_js/common_static/js/vendor/jquery.smooth-scroll.min",
"jquery.scrollTo": "xmodule_js/common_static/js/vendor/jquery.scrollTo-1.4.2-min",
"jquery.timepicker": "xmodule_js/common_static/js/vendor/timepicker/jquery.timepicker",
"jquery.cookie": "xmodule_js/common_static/js/vendor/jquery.cookie",
"jquery.qtip": "xmodule_js/common_static/js/vendor/jquery.qtip.min",
"jquery.fileupload": "xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload",
"jquery.iframe-transport": "xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport",
"jquery.inputnumber": "xmodule_js/common_static/js/vendor/html5-input-polyfills/number-polyfill",
"datepair": "xmodule_js/common_static/js/vendor/timepicker/datepair",
"date": "xmodule_js/common_static/js/vendor/date",
"underscore": "xmodule_js/common_static/js/vendor/underscore-min",
"underscore.string": "xmodule_js/common_static/js/vendor/underscore.string.min",
"backbone": "xmodule_js/common_static/js/vendor/backbone-min",
"backbone.associations": "xmodule_js/common_static/js/vendor/backbone-associations-min",
"youtube": "xmodule_js/common_static/js/load_youtube",
"tinymce": "xmodule_js/common_static/js/vendor/tiny_mce/tiny_mce",
"jquery.tinymce": "xmodule_js/common_static/js/vendor/tiny_mce/jquery.tinymce",
"mathjax": "https://edx-static.s3.amazonaws.com/mathjax-MathJax-727332c/MathJax.js?config=TeX-MML-AM_HTMLorMML-full",
"xmodule": "xmodule_js/src/xmodule",
"utility": "xmodule_js/common_static/js/src/utility",
"sinon": "xmodule_js/common_static/js/vendor/sinon-1.7.1",
"squire": "xmodule_js/common_static/js/vendor/Squire",
"jasmine-stealth": "xmodule_js/common_static/js/vendor/jasmine-stealth",
"jasmine.async": "xmodule_js/common_static/js/vendor/jasmine.async",
"coffee/src/ajax_prefix": "xmodule_js/common_static/coffee/src/ajax_prefix"
},
shim: {
"gettext": {
exports: "gettext"
},
"date": {
exports: "Date"
},
"jquery.ui": {
deps: ["jquery"],
exports: "jQuery.ui"
},
"jquery.form": {
deps: ["jquery"],
exports: "jQuery.fn.ajaxForm"
},
"jquery.markitup": {
deps: ["jquery"],
exports: "jQuery.fn.markitup"
},
"jquery.leanModal": {
deps: ["jquery"],
exports: "jQuery.fn.leanModal"
},
"jquery.smoothScroll": {
deps: ["jquery"],
exports: "jQuery.fn.smoothScroll"
},
"jquery.scrollTo": {
deps: ["jquery"],
exports: "jQuery.fn.scrollTo"
},
"jquery.cookie": {
deps: ["jquery"],
exports: "jQuery.fn.cookie"
},
"jquery.qtip": {
deps: ["jquery"],
exports: "jQuery.fn.qtip"
},
"jquery.fileupload": {
deps: ["jquery.iframe-transport"],
exports: "jQuery.fn.fileupload"
},
"jquery.inputnumber": {
deps: ["jquery"],
exports: "jQuery.fn.inputNumber"
},
"jquery.tinymce": {
deps: ["jquery", "tinymce"],
exports: "jQuery.fn.tinymce"
},
"datepair": {
deps: ["jquery.ui", "jquery.timepicker"]
},
"underscore": {
exports: "_"
},
"backbone": {
deps: ["underscore", "jquery"],
exports: "Backbone"
},
"backbone.associations": {
deps: ["backbone"],
exports: "Backbone.Associations"
},
"codemirror": {
exports: "CodeMirror"
},
"tinymce": {
exports: "tinymce"
},
"mathjax": {
exports: "MathJax"
},
"xmodule": {
exports: "XModule"
},
"sinon": {
exports: "sinon"
},
"jasmine-stealth": {
deps: ["jasmine"]
},
"jasmine.async": {
deps: ["jasmine"],
exports: "AsyncSpec"
},
"coffee/src/main": {
deps: ["coffee/src/ajax_prefix"]
},
"coffee/src/ajax_prefix": {
deps: ["jquery"]
}
}
});
jasmine.getFixtures().fixturesPath += 'coffee/fixtures'
define([
"coffee/spec/views/assets_spec"
])
describe "CMS.Models.Course", -> define ["js/models/course"], (Course) ->
describe "basic", -> describe "Course", ->
beforeEach -> describe "basic", ->
@model = new CMS.Models.Course({ beforeEach ->
@model = new Course({
name: "Greek Hero" name: "Greek Hero"
}) })
it "should take a name argument", -> it "should take a name argument", ->
expect(@model.get("name")).toEqual("Greek Hero") expect(@model.get("name")).toEqual("Greek Hero")
describe "CMS.Models.Metadata", -> define ["js/models/metadata"], (Metadata) ->
it "knows when the value has not been modified", -> describe "Metadata", ->
model = new CMS.Models.Metadata( it "knows when the value has not been modified", ->
{'value': 'original', 'explicitly_set': false}) model = new Metadata(
expect(model.isModified()).toBeFalsy() {'value': 'original', 'explicitly_set': false})
expect(model.isModified()).toBeFalsy()
model = new CMS.Models.Metadata(
{'value': 'original', 'explicitly_set': true}) model = new Metadata(
model.setValue('original') {'value': 'original', 'explicitly_set': true})
expect(model.isModified()).toBeFalsy() model.setValue('original')
expect(model.isModified()).toBeFalsy()
it "knows when the value has been modified", ->
model = new CMS.Models.Metadata( it "knows when the value has been modified", ->
{'value': 'original', 'explicitly_set': false}) model = new Metadata(
model.setValue('original') {'value': 'original', 'explicitly_set': false})
expect(model.isModified()).toBeTruthy() model.setValue('original')
expect(model.isModified()).toBeTruthy()
model = new CMS.Models.Metadata(
{'value': 'original', 'explicitly_set': true}) model = new Metadata(
model.setValue('modified') {'value': 'original', 'explicitly_set': true})
expect(model.isModified()).toBeTruthy() model.setValue('modified')
expect(model.isModified()).toBeTruthy()
it "tracks when values have been explicitly set", ->
model = new CMS.Models.Metadata( it "tracks when values have been explicitly set", ->
{'value': 'original', 'explicitly_set': false}) model = new Metadata(
expect(model.isExplicitlySet()).toBeFalsy() {'value': 'original', 'explicitly_set': false})
model.setValue('original') expect(model.isExplicitlySet()).toBeFalsy()
expect(model.isExplicitlySet()).toBeTruthy() model.setValue('original')
expect(model.isExplicitlySet()).toBeTruthy()
it "has both 'display value' and a 'value' methods", ->
model = new CMS.Models.Metadata( it "has both 'display value' and a 'value' methods", ->
{'value': 'default', 'explicitly_set': false}) model = new Metadata(
expect(model.getValue()).toBeNull {'value': 'default', 'explicitly_set': false})
expect(model.getDisplayValue()).toBe('default') expect(model.getValue()).toBeNull
model.setValue('modified') expect(model.getDisplayValue()).toBe('default')
expect(model.getValue()).toBe('modified') model.setValue('modified')
expect(model.getDisplayValue()).toBe('modified') expect(model.getValue()).toBe('modified')
expect(model.getDisplayValue()).toBe('modified')
it "has a clear method for reverting to the default", ->
model = new CMS.Models.Metadata( it "has a clear method for reverting to the default", ->
{'value': 'original', 'default_value' : 'default', 'explicitly_set': true}) model = new Metadata(
model.clear() {'value': 'original', 'default_value' : 'default', 'explicitly_set': true})
expect(model.getValue()).toBeNull model.clear()
expect(model.getDisplayValue()).toBe('default') expect(model.getValue()).toBeNull
expect(model.isExplicitlySet()).toBeFalsy() expect(model.getDisplayValue()).toBe('default')
expect(model.isExplicitlySet()).toBeFalsy()
it "has a getter for field name", ->
model = new CMS.Models.Metadata({'field_name': 'foo'}) it "has a getter for field name", ->
expect(model.getFieldName()).toBe('foo') model = new Metadata({'field_name': 'foo'})
expect(model.getFieldName()).toBe('foo')
it "has a getter for options", ->
model = new CMS.Models.Metadata({'options': ['foo', 'bar']}) it "has a getter for options", ->
expect(model.getOptions()).toEqual(['foo', 'bar']) model = new Metadata({'options': ['foo', 'bar']})
expect(model.getOptions()).toEqual(['foo', 'bar'])
it "has a getter for type", ->
model = new CMS.Models.Metadata({'type': 'Integer'}) it "has a getter for type", ->
expect(model.getType()).toBe(CMS.Models.Metadata.INTEGER_TYPE) model = new Metadata({'type': 'Integer'})
expect(model.getType()).toBe(Metadata.INTEGER_TYPE)
describe "CMS.Models.Module", -> define ["coffee/src/models/module"], (Module) ->
it "set the correct URL", -> describe "Module", ->
expect(new CMS.Models.Module().url).toEqual("/save_item") it "set the correct URL", ->
expect(new Module().url).toEqual("/save_item")
it "set the correct default", -> it "set the correct default", ->
expect(new CMS.Models.Module().defaults).toEqual(undefined) expect(new Module().defaults).toEqual(undefined)
describe "CMS.Models.Section", -> define ["js/models/section", "sinon"], (Section, sinon) ->
describe "basic", -> describe "Section", ->
beforeEach -> describe "basic", ->
@model = new CMS.Models.Section({ beforeEach ->
@model = new Section({
id: 42,
name: "Life, the Universe, and Everything"
})
it "should take an id argument", ->
expect(@model.get("id")).toEqual(42)
it "should take a name argument", ->
expect(@model.get("name")).toEqual("Life, the Universe, and Everything")
it "should have a URL set", ->
expect(@model.url).toEqual("/save_item")
it "should serialize to JSON correctly", ->
expect(@model.toJSON()).toEqual({
id: 42, id: 42,
name: "Life, the Universe, and Everything" metadata:
}) {
it "should take an id argument", ->
expect(@model.get("id")).toEqual(42)
it "should take a name argument", ->
expect(@model.get("name")).toEqual("Life, the Universe, and Everything")
it "should have a URL set", ->
expect(@model.url).toEqual("/save_item")
it "should serialize to JSON correctly", ->
expect(@model.toJSON()).toEqual({
id: 42,
metadata: {
display_name: "Life, the Universe, and Everything" display_name: "Life, the Universe, and Everything"
} }
}) })
describe "XHR", -> describe "XHR", ->
beforeEach -> beforeEach ->
spyOn(CMS.Models.Section.prototype, 'showNotification') spyOn(Section.prototype, 'showNotification')
spyOn(CMS.Models.Section.prototype, 'hideNotification') spyOn(Section.prototype, 'hideNotification')
@model = new CMS.Models.Section({ @model = new Section({
id: 42, id: 42,
name: "Life, the Universe, and Everything" name: "Life, the Universe, and Everything"
}) })
@requests = requests = [] @requests = requests = []
@xhr = sinon.useFakeXMLHttpRequest() @xhr = sinon.useFakeXMLHttpRequest()
@xhr.onCreate = (xhr) -> requests.push(xhr) @xhr.onCreate = (xhr) -> requests.push(xhr)
afterEach -> afterEach ->
@xhr.restore() @xhr.restore()
it "show/hide a notification when it saves to the server", -> it "show/hide a notification when it saves to the server", ->
@model.save() @model.save()
expect(CMS.Models.Section.prototype.showNotification).toHaveBeenCalled() expect(Section.prototype.showNotification).toHaveBeenCalled()
@requests[0].respond(200) @requests[0].respond(200)
expect(CMS.Models.Section.prototype.hideNotification).toHaveBeenCalled() expect(Section.prototype.hideNotification).toHaveBeenCalled()
it "don't hide notification when saving fails", -> it "don't hide notification when saving fails", ->
# this is handled by the global AJAX error handler # this is handled by the global AJAX error handler
@model.save() @model.save()
@requests[0].respond(500) @requests[0].respond(500)
expect(CMS.Models.Section.prototype.hideNotification).not.toHaveBeenCalled() expect(Section.prototype.hideNotification).not.toHaveBeenCalled()
describe "CMS.Models.Settings.CourseGradingPolicy", -> define ["js/models/settings/course_grading_policy"], (CourseGradingPolicy) ->
beforeEach -> describe "CourseGradingPolicy", ->
@model = new CMS.Models.Settings.CourseGradingPolicy() beforeEach ->
@model = new CourseGradingPolicy()
describe "parse", -> describe "parse", ->
it "sets a null grace period to 00:00", -> it "sets a null grace period to 00:00", ->
attrs = @model.parse(grace_period: null) attrs = @model.parse(grace_period: null)
expect(attrs.grace_period).toEqual( expect(attrs.grace_period).toEqual(
hours: 0, hours: 0,
minutes: 0 minutes: 0
) )
describe "parseGracePeriod", -> describe "parseGracePeriod", ->
it "parses a time in HH:MM format", -> it "parses a time in HH:MM format", ->
time = @model.parseGracePeriod("07:19") time = @model.parseGracePeriod("07:19")
expect(time).toEqual( expect(time).toEqual(
hours: 7, hours: 7,
minutes: 19 minutes: 19
) )
it "returns null on an incorrectly formatted string", -> it "returns null on an incorrectly formatted string", ->
expect(@model.parseGracePeriod("asdf")).toBe(null) expect(@model.parseGracePeriod("asdf")).toBe(null)
expect(@model.parseGracePeriod("7:19")).toBe(null) expect(@model.parseGracePeriod("7:19")).toBe(null)
expect(@model.parseGracePeriod("1000:00")).toBe(null) expect(@model.parseGracePeriod("1000:00")).toBe(null)
describe "CMS.Models.FileUpload", -> define ["js/models/uploads"], (FileUpload) ->
beforeEach ->
@model = new CMS.Models.FileUpload() describe "FileUpload", ->
beforeEach ->
it "is unfinished by default", -> @model = new FileUpload()
expect(@model.get("finished")).toBeFalsy()
it "is unfinished by default", ->
it "is not uploading by default", -> expect(@model.get("finished")).toBeFalsy()
expect(@model.get("uploading")).toBeFalsy()
it "is not uploading by default", ->
it "is valid by default", -> expect(@model.get("uploading")).toBeFalsy()
expect(@model.isValid()).toBeTruthy()
it "is valid by default", ->
it "is invalid for text files by default", -> expect(@model.isValid()).toBeTruthy()
file = {"type": "text/plain"}
@model.set("selectedFile", file); it "is invalid for text files by default", ->
expect(@model.isValid()).toBeFalsy() file = {"type": "text/plain"}
@model.set("selectedFile", file);
it "is invalid for PNG files by default", -> expect(@model.isValid()).toBeFalsy()
file = {"type": "image/png"}
@model.set("selectedFile", file); it "is invalid for PNG files by default", ->
expect(@model.isValid()).toBeFalsy() file = {"type": "image/png"}
@model.set("selectedFile", file);
it "can accept a file type when explicitly set", -> expect(@model.isValid()).toBeFalsy()
file = {"type": "image/png"}
@model.set("mimeTypes": ["image/png"]) it "can accept a file type when explicitly set", ->
@model.set("selectedFile", file) file = {"type": "image/png"}
expect(@model.isValid()).toBeTruthy() @model.set("mimeTypes": ["image/png"])
@model.set("selectedFile", file)
it "can accept multiple file types", -> expect(@model.isValid()).toBeTruthy()
file = {"type": "image/gif"}
@model.set("mimeTypes": ["image/png", "image/jpeg", "image/gif"]) it "can accept multiple file types", ->
@model.set("selectedFile", file) file = {"type": "image/gif"}
expect(@model.isValid()).toBeTruthy() @model.set("mimeTypes": ["image/png", "image/jpeg", "image/gif"])
@model.set("selectedFile", file)
describe "fileTypes", -> expect(@model.isValid()).toBeTruthy()
it "returns a list of the uploader's file types", ->
@model.set('mimeTypes', ['image/png', 'application/json']) describe "fileTypes", ->
expect(@model.fileTypes()).toEqual(['PNG', 'JSON']) it "returns a list of the uploader's file types", ->
@model.set('mimeTypes', ['image/png', 'application/json'])
describe "formatValidTypes", -> expect(@model.fileTypes()).toEqual(['PNG', 'JSON'])
it "returns a map of formatted file types and extensions", ->
@model.set('mimeTypes', ['image/png', 'image/jpeg', 'application/json']) describe "formatValidTypes", ->
formatted = @model.formatValidTypes() it "returns a map of formatted file types and extensions", ->
expect(formatted).toEqual( @model.set('mimeTypes', ['image/png', 'image/jpeg', 'application/json'])
fileTypes: 'PNG, JPEG or JSON', formatted = @model.formatValidTypes()
fileExtensions: '.png, .jpeg or .json' expect(formatted).toEqual(
) fileTypes: 'PNG, JPEG or JSON',
fileExtensions: '.png, .jpeg or .json'
it "does not format with only one mime type", -> )
@model.set('mimeTypes', ['application/pdf'])
formatted = @model.formatValidTypes() it "does not format with only one mime type", ->
expect(formatted).toEqual( @model.set('mimeTypes', ['application/pdf'])
fileTypes: 'PDF', formatted = @model.formatValidTypes()
fileExtensions: '.pdf' expect(formatted).toEqual(
) fileTypes: 'PDF',
fileExtensions: '.pdf'
)
require =
baseUrl: "/suite/cms/include"
paths:
"jquery": "xmodule_js/common_static/js/vendor/jquery.min",
"jquery.ui" : "xmodule_js/common_static/js/vendor/jquery-ui.min",
"jquery.cookie": "xmodule_js/common_static/js/vendor/jquery.cookie",
"underscore": "xmodule_js/common_static/js/vendor/underscore-min",
"underscore.string": "xmodule_js/common_static/js/vendor/underscore.string.min",
"backbone": "xmodule_js/common_static/js/vendor/backbone-min",
"backbone.associations": "xmodule_js/common_static/js/vendor/backbone-associations-min",
"jquery.timepicker": "xmodule_js/common_static/js/vendor/timepicker/jquery.timepicker",
"jquery.leanModal": "xmodule_js/common_static/js/vendor/jquery.leanModal.min",
"jquery.scrollTo": "xmodule_js/common_static/js/vendor/jquery.scrollTo-1.4.2-min",
"jquery.flot": "xmodule_js/common_static/js/vendor/flot/jquery.flot.min",
"jquery.form": "xmodule_js/common_static/js/vendor/jquery.form",
"jquery.inputnumber": "xmodule_js/common_static/js/vendor/html5-input-polyfills/number-polyfill",
"sinon": "xmodule_js/common_static/js/vendor/sinon-1.7.1",
"xmodule": "xmodule_js/src/xmodule",
"gettext": "xmodule_js/common_static/js/test/i18n",
"utility": "xmodule_js/common_static/js/src/utility",
"codemirror": "xmodule_js/common_static/js/vendor/CodeMirror/codemirror"
shim:
"gettext":
exports: "gettext"
"jquery.ui":
deps: ["jquery"]
exports: "jQuery.ui"
"jquery.form":
deps: ["jquery"]
exports: "jQuery.fn.ajaxForm"
"jquery.inputnumber":
deps: ["jquery"]
exports: "jQuery.fn.inputNumber"
"jquery.leanModal":
deps: ["jquery"],
exports: "jQuery.fn.leanModal"
"jquery.cookie":
deps: ["jquery"],
exports: "jQuery.fn.cookie"
"jquery.scrollTo":
deps: ["jquery"],
exports: "jQuery.fn.scrollTo"
"jquery.flot":
deps: ["jquery"],
exports: "jQuery.fn.plot"
"underscore":
exports: "_"
"backbone":
deps: ["underscore", "jquery"],
exports: "Backbone"
"backbone.associations":
deps: ["backbone"],
exports: "Backbone.Associations"
"xmodule":
exports: "XModule"
"sinon":
exports: "sinon"
"codemirror":
exports: "CodeMirror"
# load these automatically
deps: ["js/base", "coffee/src/main"]
describe "CMS.Views.ModuleEdit", -> define ["coffee/src/views/module_edit", "xmodule"], (ModuleEdit) ->
beforeEach ->
@stubModule = jasmine.createSpy("CMS.Models.Module")
@stubModule.id = 'stub-id'
describe "ModuleEdit", ->
beforeEach ->
@stubModule = jasmine.createSpy("Module")
@stubModule.id = 'stub-id'
setFixtures """
<li class="component" id="stub-id">
<div class="component-editor">
<div class="module-editor">
${editor}
</div>
<a href="#" class="save-button">Save</a>
<a href="#" class="cancel-button">Cancel</a>
</div>
<div class="component-actions">
<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>
<a href="#" class="drag-handle"></a>
<section class="xmodule_display xmodule_stub" data-type="StubModule">
<div id="stub-module-content"/>
</section>
</li>
"""
spyOn($.fn, 'load').andReturn(@moduleData)
@moduleEdit = new CMS.Views.ModuleEdit(
el: $(".component")
model: @stubModule
onDelete: jasmine.createSpy()
)
CMS.unbind()
describe "class definition", ->
it "sets the correct tagName", ->
expect(@moduleEdit.tagName).toEqual("li")
it "sets the correct className", -> setFixtures """
expect(@moduleEdit.className).toEqual("component") <li class="component" id="stub-id">
<div class="component-editor">
<div class="module-editor">
${editor}
</div>
<a href="#" class="save-button">Save</a>
<a href="#" class="cancel-button">Cancel</a>
</div>
<div class="component-actions">
<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>
<a href="#" class="drag-handle"></a>
<section class="xmodule_display xmodule_stub" data-type="StubModule">
<div id="stub-module-content"/>
</section>
</li>
"""
spyOn($.fn, 'load').andReturn(@moduleData)
describe "methods", -> @moduleEdit = new ModuleEdit(
describe "initialize", ->
beforeEach ->
spyOn(CMS.Views.ModuleEdit.prototype, 'render')
@moduleEdit = new CMS.Views.ModuleEdit(
el: $(".component") el: $(".component")
model: @stubModule model: @stubModule
onDelete: jasmine.createSpy() onDelete: jasmine.createSpy()
) )
it "renders the module editor", -> describe "class definition", ->
expect(@moduleEdit.render).toHaveBeenCalled() it "sets the correct tagName", ->
expect(@moduleEdit.tagName).toEqual("li")
describe "render", -> it "sets the correct className", ->
beforeEach -> expect(@moduleEdit.className).toEqual("component")
spyOn(@moduleEdit, 'loadDisplay')
spyOn(@moduleEdit, 'delegateEvents')
@moduleEdit.render()
it "loads the module preview and editor via ajax on the view element", -> describe "methods", ->
expect(@moduleEdit.$el.load).toHaveBeenCalledWith("/preview_component/#{@moduleEdit.model.id}", jasmine.any(Function)) describe "initialize", ->
@moduleEdit.$el.load.mostRecentCall.args[1]() beforeEach ->
expect(@moduleEdit.loadDisplay).toHaveBeenCalled() spyOn(ModuleEdit.prototype, 'render')
expect(@moduleEdit.delegateEvents).toHaveBeenCalled() @moduleEdit = new ModuleEdit(
el: $(".component")
model: @stubModule
onDelete: jasmine.createSpy()
)
describe "loadDisplay", -> it "renders the module editor", ->
beforeEach -> expect(@moduleEdit.render).toHaveBeenCalled()
spyOn(XModule, 'loadModule')
@moduleEdit.loadDisplay() describe "render", ->
beforeEach ->
spyOn(@moduleEdit, 'loadDisplay')
spyOn(@moduleEdit, 'delegateEvents')
@moduleEdit.render()
it "loads the module preview and editor via ajax on the view element", ->
expect(@moduleEdit.$el.load).toHaveBeenCalledWith("/preview_component/#{@moduleEdit.model.id}", jasmine.any(Function))
@moduleEdit.$el.load.mostRecentCall.args[1]()
expect(@moduleEdit.loadDisplay).toHaveBeenCalled()
expect(@moduleEdit.delegateEvents).toHaveBeenCalled()
describe "loadDisplay", ->
beforeEach ->
spyOn(XModule, 'loadModule')
@moduleEdit.loadDisplay()
it "loads the .xmodule-display inside the module editor", -> it "loads the .xmodule-display inside the module editor", ->
expect(XModule.loadModule).toHaveBeenCalled() expect(XModule.loadModule).toHaveBeenCalled()
expect(XModule.loadModule.mostRecentCall.args[0]).toBe($('.xmodule_display')) expect(XModule.loadModule.mostRecentCall.args[0]).toBe($('.xmodule_display'))
describe "Course Overview", ->
beforeEach ->
_.each ["/static/js/vendor/date.js", "/static/js/vendor/timepicker/jquery.timepicker.js", "/jsi18n/"], (path) ->
appendSetFixtures """
<script type="text/javascript" src="#{path}"></script>
"""
appendSetFixtures """
<div class="section-published-date">
<span class="published-status">
<strong>Will Release:</strong> 06/12/2013 at 04:00 UTC
</span>
<a href="#" class="edit-button" data-date="06/12/2013" data-time="04:00" data-id="i4x://pfogg/42/chapter/d6b47f7b084f49debcaf67fe5436c8e2">Edit</a>
</div>
"""
appendSetFixtures """
<div class="edit-subsection-publish-settings">
<div class="settings">
<h3>Section Release Date</h3>
<div class="picker datepair">
<div class="field field-start-date">
<label for="">Release Day</label>
<input class="start-date date" type="text" name="start_date" value="04/08/1990" placeholder="MM/DD/YYYY" class="date" size='15' autocomplete="off"/>
</div>
<div class="field field-start-time">
<label for="">Release Time (<abbr title="Coordinated Universal Time">UTC</abbr>)</label>
<input class="start-time time" type="text" name="start_time" value="12:00" placeholder="HH:MM" class="time" size='10' autocomplete="off"/>
</div>
<div class="description">
<p>On the date set above, this section – <strong class="section-name"></strong> – will be released to students. Any units marked private will only be visible to admins.</p>
</div>
</div>
<a href="#" class="save-button">Save</a><a href="#" class="cancel-button">Cancel</a>
</div>
</div>
"""
appendSetFixtures """
<section class="courseware-section branch" data-id="a-location-goes-here">
<li class="branch collapsed id-holder" data-id="an-id-goes-here">
<a href="#" class="delete-section-button"></a>
</li>
</section>
"""
spyOn(window, 'saveSetSectionScheduleDate').andCallThrough()
# Have to do this here, as it normally gets bound in document.ready()
$('a.save-button').click(saveSetSectionScheduleDate)
$('a.delete-section-button').click(deleteSection)
$(".edit-subsection-publish-settings .start-date").datepicker()
@notificationSpy = spyOn(CMS.Views.Notification.Mini.prototype, 'show').andCallThrough()
window.analytics = jasmine.createSpyObj('analytics', ['track'])
window.course_location_analytics = jasmine.createSpy()
@xhr = sinon.useFakeXMLHttpRequest()
requests = @requests = []
@xhr.onCreate = (req) -> requests.push(req)
afterEach ->
delete window.analytics
delete window.course_location_analytics
@notificationSpy.reset()
it "should save model when save is clicked", ->
$('a.edit-button').click()
$('a.save-button').click()
expect(saveSetSectionScheduleDate).toHaveBeenCalled()
it "should show a confirmation on save", ->
$('a.edit-button').click()
$('a.save-button').click()
expect(@notificationSpy).toHaveBeenCalled()
it "should delete model when delete is clicked", ->
deleteSpy = spyOn(window, '_deleteItem').andCallThrough()
$('a.delete-section-button').click()
$('a.action-primary').click()
expect(deleteSpy).toHaveBeenCalled()
expect(@requests[0].url).toEqual('/delete_item')
it "should not delete model when cancel is clicked", ->
deleteSpy = spyOn(window, '_deleteItem').andCallThrough()
$('a.delete-section-button').click()
$('a.action-secondary').click()
expect(@requests.length).toEqual(0)
it "should show a confirmation on delete", ->
$('a.delete-section-button').click()
$('a.action-primary').click()
expect(@notificationSpy).toHaveBeenCalled()
describe "CMS.Views.SectionShow", -> define ["js/models/section", "js/views/section_show", "js/views/section_edit", "sinon"], (Section, SectionShow, SectionEdit, sinon) ->
describe "Basic", ->
beforeEach ->
spyOn(CMS.Views.SectionShow.prototype, "switchToEditView")
.andCallThrough()
@model = new CMS.Models.Section({
id: 42
name: "Life, the Universe, and Everything"
})
@view = new CMS.Views.SectionShow({model: @model})
@view.render()
it "should contain the model name", -> describe "SectionShow", ->
expect(@view.$el).toHaveText(@model.get('name')) describe "Basic", ->
beforeEach ->
spyOn(SectionShow.prototype, "switchToEditView")
.andCallThrough()
@model = new Section({
id: 42
name: "Life, the Universe, and Everything"
})
@view = new SectionShow({model: @model})
@view.render()
it "should call switchToEditView when clicked", -> it "should contain the model name", ->
@view.$el.click() expect(@view.$el).toHaveText(@model.get('name'))
expect(@view.switchToEditView).toHaveBeenCalled()
it "should pass the same element to SectionEdit when switching views", -> it "should call switchToEditView when clicked", ->
spyOn(CMS.Views.SectionEdit.prototype, 'initialize').andCallThrough() @view.$el.click()
@view.switchToEditView() expect(@view.switchToEditView).toHaveBeenCalled()
expect(CMS.Views.SectionEdit.prototype.initialize).toHaveBeenCalled()
expect(CMS.Views.SectionEdit.prototype.initialize.mostRecentCall.args[0].el).toEqual(@view.el)
describe "CMS.Views.SectionEdit", -> it "should pass the same element to SectionEdit when switching views", ->
describe "Basic", -> spyOn(SectionEdit.prototype, 'initialize').andCallThrough()
tpl = readFixtures('section-name-edit.underscore') @view.switchToEditView()
feedback_tpl = readFixtures('system-feedback.underscore') expect(SectionEdit.prototype.initialize).toHaveBeenCalled()
expect(SectionEdit.prototype.initialize.mostRecentCall.args[0].el).toEqual(@view.el)
beforeEach -> describe "SectionEdit", ->
setFixtures($("<script>", {id: "section-name-edit-tpl", type: "text/template"}).text(tpl)) describe "Basic", ->
appendSetFixtures($("<script>", {id: "system-feedback-tpl", type: "text/template"}).text(feedback_tpl)) tpl = readFixtures('section-name-edit.underscore')
spyOn(CMS.Views.SectionEdit.prototype, "switchToShowView") feedback_tpl = readFixtures('system-feedback.underscore')
.andCallThrough()
spyOn(CMS.Views.SectionEdit.prototype, "showInvalidMessage")
.andCallThrough()
window.analytics = jasmine.createSpyObj('analytics', ['track'])
window.course_location_analytics = jasmine.createSpy()
@requests = requests = []
@xhr = sinon.useFakeXMLHttpRequest()
@xhr.onCreate = (xhr) -> requests.push(xhr)
@model = new CMS.Models.Section({ beforeEach ->
id: 42 setFixtures($("<script>", {id: "section-name-edit-tpl", type: "text/template"}).text(tpl))
name: "Life, the Universe, and Everything" appendSetFixtures($("<script>", {id: "system-feedback-tpl", type: "text/template"}).text(feedback_tpl))
}) spyOn(SectionEdit.prototype, "switchToShowView")
@view = new CMS.Views.SectionEdit({model: @model}) .andCallThrough()
@view.render() spyOn(SectionEdit.prototype, "showInvalidMessage")
.andCallThrough()
window.analytics = jasmine.createSpyObj('analytics', ['track'])
window.course_location_analytics = jasmine.createSpy()
@requests = requests = []
@xhr = sinon.useFakeXMLHttpRequest()
@xhr.onCreate = (xhr) -> requests.push(xhr)
afterEach -> @model = new Section({
@xhr.restore() id: 42
delete window.analytics name: "Life, the Universe, and Everything"
delete window.course_location_analytics })
@view = new SectionEdit({model: @model})
@view.render()
it "should have the model name as the default text value", -> afterEach ->
expect(@view.$("input[type=text]").val()).toEqual(@model.get('name')) @xhr.restore()
delete window.analytics
delete window.course_location_analytics
it "should call switchToShowView when cancel button is clicked", -> it "should have the model name as the default text value", ->
@view.$("input.cancel-button").click() expect(@view.$("input[type=text]").val()).toEqual(@model.get('name'))
expect(@view.switchToShowView).toHaveBeenCalled()
it "should save model when save button is clicked", -> it "should call switchToShowView when cancel button is clicked", ->
spyOn(@model, 'save') @view.$("input.cancel-button").click()
@view.$("input[type=submit]").click() expect(@view.switchToShowView).toHaveBeenCalled()
expect(@model.save).toHaveBeenCalled()
it "should call switchToShowView when save() is successful", -> it "should save model when save button is clicked", ->
@view.$("input[type=submit]").click() spyOn(@model, 'save')
@requests[0].respond(200) @view.$("input[type=submit]").click()
expect(@view.switchToShowView).toHaveBeenCalled() expect(@model.save).toHaveBeenCalled()
it "should call showInvalidMessage when validation is unsuccessful", -> it "should call switchToShowView when save() is successful", ->
spyOn(@model, 'validate').andReturn("BLARRGH") @view.$("input[type=submit]").click()
@view.$("input[type=submit]").click() @requests[0].respond(200)
expect(@view.showInvalidMessage).toHaveBeenCalledWith( expect(@view.switchToShowView).toHaveBeenCalled()
jasmine.any(Object), "BLARRGH", jasmine.any(Object))
expect(@view.switchToShowView).not.toHaveBeenCalled()
it "should not save when validation is unsuccessful", -> it "should call showInvalidMessage when validation is unsuccessful", ->
spyOn(@model, 'validate').andReturn("BLARRGH") spyOn(@model, 'validate').andReturn("BLARRGH")
@view.$("input[type=text]").val("changed") @view.$("input[type=submit]").click()
@view.$("input[type=submit]").click() expect(@view.showInvalidMessage).toHaveBeenCalledWith(
expect(@model.get('name')).not.toEqual("changed") jasmine.any(Object), "BLARRGH", jasmine.any(Object))
expect(@view.switchToShowView).not.toHaveBeenCalled()
it "should not save when validation is unsuccessful", ->
spyOn(@model, 'validate').andReturn("BLARRGH")
@view.$("input[type=text]").val("changed")
@view.$("input[type=submit]").click()
expect(@model.get('name')).not.toEqual("changed")
feedbackTpl = readFixtures('system-feedback.underscore') define ["js/models/uploads", "js/views/uploads", "js/models/chapter", "sinon"], (FileUpload, UploadDialog, Chapter, sinon) ->
describe "CMS.Views.UploadDialog", -> feedbackTpl = readFixtures('system-feedback.underscore')
tpl = readFixtures("upload-dialog.underscore")
describe "UploadDialog", ->
beforeEach -> tpl = readFixtures("upload-dialog.underscore")
setFixtures($("<script>", {id: "upload-dialog-tpl", type: "text/template"}).text(tpl))
appendSetFixtures($("<script>", {id: "system-feedback-tpl", type: "text/template"}).text(feedbackTpl))
CMS.URL.UPLOAD_ASSET = "/upload"
@model = new CMS.Models.FileUpload(
mimeTypes: ['application/pdf']
)
@dialogResponse = dialogResponse = []
@view = new CMS.Views.UploadDialog(
model: @model,
onSuccess: (response) =>
dialogResponse.push(response.response)
)
spyOn(@view, 'remove').andCallThrough()
# create mock file input, so that we aren't subject to browser restrictions
@mockFiles = []
mockFileInput = jasmine.createSpy('mockFileInput')
mockFileInput.files = @mockFiles
jqMockFileInput = jasmine.createSpyObj('jqMockFileInput', ['get', 'replaceWith'])
jqMockFileInput.get.andReturn(mockFileInput)
realMethod = @view.$
spyOn(@view, "$").andCallFake (selector) ->
if selector == "input[type=file]"
jqMockFileInput
else
realMethod.apply(this, arguments)
afterEach ->
delete CMS.URL.UPLOAD_ASSET
describe "Basic", ->
it "should be shown by default", ->
expect(@view.options.shown).toBeTruthy()
it "should render without a file selected", ->
@view.render()
expect(@view.$el).toContain("input[type=file]")
expect(@view.$(".action-upload")).toHaveClass("disabled")
it "should render with a PDF selected", ->
file = {name: "fake.pdf", "type": "application/pdf"}
@mockFiles.push(file)
@model.set("selectedFile", file)
@view.render()
expect(@view.$el).toContain("input[type=file]")
expect(@view.$el).not.toContain("#upload_error")
expect(@view.$(".action-upload")).not.toHaveClass("disabled")
it "should render an error with an invalid file type selected", ->
file = {name: "fake.png", "type": "image/png"}
@mockFiles.push(file)
@model.set("selectedFile", file)
@view.render()
expect(@view.$el).toContain("input[type=file]")
expect(@view.$el).toContain("#upload_error")
expect(@view.$(".action-upload")).toHaveClass("disabled")
it "adds body class on show()", ->
@view.show()
expect(@view.options.shown).toBeTruthy()
# can't test: this blows up the spec runner
# expect($("body")).toHaveClass("dialog-is-shown")
it "removes body class on hide()", ->
@view.hide()
expect(@view.options.shown).toBeFalsy()
# can't test: this blows up the spec runner
# expect($("body")).not.toHaveClass("dialog-is-shown")
describe "Uploads", ->
beforeEach -> beforeEach ->
@requests = requests = [] setFixtures($("<script>", {id: "upload-dialog-tpl", type: "text/template"}).text(tpl))
@xhr = sinon.useFakeXMLHttpRequest() appendSetFixtures($("<script>", {id: "system-feedback-tpl", type: "text/template"}).text(feedbackTpl))
@xhr.onCreate = (xhr) -> requests.push(xhr) CMS.URL.UPLOAD_ASSET = "/upload"
@clock = sinon.useFakeTimers()
@model = new FileUpload(
mimeTypes: ['application/pdf']
)
@dialogResponse = dialogResponse = []
@view = new UploadDialog(
model: @model,
onSuccess: (response) =>
dialogResponse.push(response.response)
)
spyOn(@view, 'remove').andCallThrough()
# create mock file input, so that we aren't subject to browser restrictions
@mockFiles = []
mockFileInput = jasmine.createSpy('mockFileInput')
mockFileInput.files = @mockFiles
jqMockFileInput = jasmine.createSpyObj('jqMockFileInput', ['get', 'replaceWith'])
jqMockFileInput.get.andReturn(mockFileInput)
realMethod = @view.$
spyOn(@view, "$").andCallFake (selector) ->
if selector == "input[type=file]"
jqMockFileInput
else
realMethod.apply(this, arguments)
afterEach -> afterEach ->
@xhr.restore() delete CMS.URL.UPLOAD_ASSET
@clock.restore()
describe "Basic", ->
it "can upload correctly", -> it "should be shown by default", ->
@view.upload() expect(@view.options.shown).toBeTruthy()
expect(@model.get("uploading")).toBeTruthy()
expect(@requests.length).toEqual(1) it "should render without a file selected", ->
request = @requests[0] @view.render()
expect(request.url).toEqual("/upload") expect(@view.$el).toContain("input[type=file]")
expect(request.method).toEqual("POST") expect(@view.$(".action-upload")).toHaveClass("disabled")
request.respond(200, {"Content-Type": "application/json"}, it "should render with a PDF selected", ->
'{"response": "dummy_response"}') file = {name: "fake.pdf", "type": "application/pdf"}
expect(@model.get("uploading")).toBeFalsy() @mockFiles.push(file)
expect(@model.get("finished")).toBeTruthy() @model.set("selectedFile", file)
expect(@dialogResponse.pop()).toEqual("dummy_response") @view.render()
expect(@view.$el).toContain("input[type=file]")
it "can handle upload errors", -> expect(@view.$el).not.toContain("#upload_error")
@view.upload() expect(@view.$(".action-upload")).not.toHaveClass("disabled")
@requests[0].respond(500)
expect(@model.get("title")).toMatch(/error/) it "should render an error with an invalid file type selected", ->
expect(@view.remove).not.toHaveBeenCalled() file = {name: "fake.png", "type": "image/png"}
@mockFiles.push(file)
it "removes itself after two seconds on successful upload", -> @model.set("selectedFile", file)
@view.upload() @view.render()
@requests[0].respond(200, {"Content-Type": "application/json"}, expect(@view.$el).toContain("input[type=file]")
'{"response": "dummy_response"}') expect(@view.$el).toContain("#upload_error")
expect(@view.remove).not.toHaveBeenCalled() expect(@view.$(".action-upload")).toHaveClass("disabled")
@clock.tick(2001)
expect(@view.remove).toHaveBeenCalled() it "adds body class on show()", ->
@view.show()
expect(@view.options.shown).toBeTruthy()
# can't test: this blows up the spec runner
# expect($("body")).toHaveClass("dialog-is-shown")
it "removes body class on hide()", ->
@view.hide()
expect(@view.options.shown).toBeFalsy()
# can't test: this blows up the spec runner
# expect($("body")).not.toHaveClass("dialog-is-shown")
describe "Uploads", ->
beforeEach ->
@requests = requests = []
@xhr = sinon.useFakeXMLHttpRequest()
@xhr.onCreate = (xhr) -> requests.push(xhr)
@clock = sinon.useFakeTimers()
afterEach ->
@xhr.restore()
@clock.restore()
it "can upload correctly", ->
@view.upload()
expect(@model.get("uploading")).toBeTruthy()
expect(@requests.length).toEqual(1)
request = @requests[0]
expect(request.url).toEqual("/upload")
expect(request.method).toEqual("POST")
request.respond(200, {"Content-Type": "application/json"},
'{"response": "dummy_response"}')
expect(@model.get("uploading")).toBeFalsy()
expect(@model.get("finished")).toBeTruthy()
expect(@dialogResponse.pop()).toEqual("dummy_response")
it "can handle upload errors", ->
@view.upload()
@requests[0].respond(500)
expect(@model.get("title")).toMatch(/error/)
expect(@view.remove).not.toHaveBeenCalled()
it "removes itself after two seconds on successful upload", ->
@view.upload()
@requests[0].respond(200, {"Content-Type": "application/json"},
'{"response": "dummy_response"}')
expect(@view.remove).not.toHaveBeenCalled()
@clock.tick(2001)
expect(@view.remove).toHaveBeenCalled()
AjaxPrefix.addAjaxPrefix(jQuery, -> CMS.prefix) define ["jquery", "underscore.string", "backbone", "js/views/feedback_notification", "jquery.cookie"],
($, str, Backbone, NotificationView) ->
@CMS = AjaxPrefix.addAjaxPrefix jQuery, ->
Models: {} $("meta[name='path_prefix']").attr('content')
Views: {}
Collections: {}
URL: {}
prefix: $("meta[name='path_prefix']").attr('content')
_.extend CMS, Backbone.Events
$ ->
Backbone.emulateHTTP = true
$.ajaxSetup
headers : { 'X-CSRFToken': $.cookie 'csrftoken' }
dataType: 'json'
$(document).ajaxError (event, jqXHR, ajaxSettings, thrownError) ->
if ajaxSettings.notifyOnError is false
return
if jqXHR.responseText
try
message = JSON.parse(jqXHR.responseText).error
catch error
message = _.str.truncate(jqXHR.responseText, 300)
else
message = gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.")
msg = new CMS.Views.Notification.Error(
"title": gettext("Studio's having trouble saving your work")
"message": message
)
msg.show()
window.CMS = window.CMS or {}
CMS.URL = CMS.URL or {}
window.onTouchBasedDevice = -> window.onTouchBasedDevice = ->
navigator.userAgent.match /iPhone|iPod|iPad/i navigator.userAgent.match /iPhone|iPod|iPad/i
$('body').addClass 'touch-based-device' if onTouchBasedDevice() _.extend CMS, Backbone.Events
main = ->
Backbone.emulateHTTP = true
$.ajaxSetup
headers : { 'X-CSRFToken': $.cookie 'csrftoken' }
dataType: 'json'
$(document).ajaxError (event, jqXHR, ajaxSettings, thrownError) ->
if ajaxSettings.notifyOnError is false
return
if jqXHR.responseText
try
message = JSON.parse(jqXHR.responseText).error
catch error
message = str.truncate(jqXHR.responseText, 300)
else
message = gettext("This may be happening because of an error with our server or your internet connection. Try refreshing the page or making sure you are online.")
msg = new NotificationView.Error(
"title": gettext("Studio's having trouble saving your work")
"message": message
)
msg.show()
if onTouchBasedDevice()
$('body').addClass 'touch-based-device'
$(main)
return main
class CMS.Models.Module extends Backbone.Model define ["backbone"], (Backbone) ->
url: '/save_item' class Module extends Backbone.Model
url: '/save_item'
class CMS.Views.TabsEdit extends Backbone.View define ["jquery", "jquery.ui", "backbone", "js/views/feedback_prompt", "js/views/feedback_notification", "coffee/src/models/module", "coffee/src/views/module_edit"],
($, ui, Backbone, PromptView, NotificationView, ModuleModel, ModuleEditView) ->
class TabsEdit extends Backbone.View
initialize: => initialize: =>
@$('.component').each((idx, element) => @$('.component').each((idx, element) =>
new CMS.Views.ModuleEdit( new ModuleEditView(
el: element, el: element,
onDelete: @deleteTab, onDelete: @deleteTab,
model: new CMS.Models.Module( model: new ModuleModel(
id: $(element).data('id'), id: $(element).data('id'),
) )
) )
) )
@options.mast.find('.new-tab').on('click', @addNewTab) @options.mast.find('.new-tab').on('click', @addNewTab)
@$('.components').sortable( @$('.components').sortable(
handle: '.drag-handle' handle: '.drag-handle'
update: @tabMoved update: @tabMoved
helper: 'clone' helper: 'clone'
opacity: '0.5' opacity: '0.5'
placeholder: 'component-placeholder' placeholder: 'component-placeholder'
forcePlaceholderSize: true forcePlaceholderSize: true
axis: 'y' axis: 'y'
items: '> .component' items: '> .component'
) )
tabMoved: (event, ui) => tabMoved: (event, ui) =>
tabs = [] tabs = []
@$('.component').each((idx, element) => @$('.component').each((idx, element) =>
tabs.push($(element).data('id')) tabs.push($(element).data('id'))
) )
analytics.track "Reordered Static Pages", analytics.track "Reordered Static Pages",
course: course_location_analytics course: course_location_analytics
$.ajax({ $.ajax({
type:'POST', type:'POST',
url: '/reorder_static_tabs', url: '/reorder_static_tabs',
data: JSON.stringify({ data: JSON.stringify({
tabs : tabs tabs : tabs
}), }),
contentType: 'application/json' contentType: 'application/json'
}) })
addNewTab: (event) => addNewTab: (event) =>
event.preventDefault() event.preventDefault()
editor = new CMS.Views.ModuleEdit( editor = new ModuleEditView(
onDelete: @deleteTab onDelete: @deleteTab
model: new CMS.Models.Module() model: new ModuleModel()
) )
$('.new-component-item').before(editor.$el) $('.new-component-item').before(editor.$el)
editor.$el.addClass('new') editor.$el.addClass('new')
setTimeout(=> setTimeout(=>
editor.$el.removeClass('new') editor.$el.removeClass('new')
, 500) , 500)
editor.createItem( editor.createItem(
@model.get('id'), @model.get('id'),
{category: 'static_tab'} {category: 'static_tab'}
) )
analytics.track "Added Static Page", analytics.track "Added Static Page",
course: course_location_analytics course: course_location_analytics
deleteTab: (event) => deleteTab: (event) =>
confirm = new CMS.Views.Prompt.Warning confirm = new PromptView.Warning
title: gettext('Delete Component Confirmation') title: gettext('Delete Component Confirmation')
message: gettext('Are you sure you want to delete this component? This action cannot be undone.') message: gettext('Are you sure you want to delete this component? This action cannot be undone.')
actions: actions:
primary: primary:
text: gettext("OK") text: gettext("OK")
click: (view) -> click: (view) ->
view.hide() view.hide()
$component = $(event.currentTarget).parents('.component') $component = $(event.currentTarget).parents('.component')
analytics.track "Deleted Static Page", analytics.track "Deleted Static Page",
course: course_location_analytics course: course_location_analytics
id: $component.data('id') id: $component.data('id')
deleting = new CMS.Views.Notification.Mini deleting = new NotificationView.Mini
title: gettext('Deleting&hellip;') title: gettext('Deleting&hellip;')
deleting.show() deleting.show()
$.post('/delete_item', { $.post('/delete_item', {
id: $component.data('id') id: $component.data('id')
}, => }, =>
$component.remove() $component.remove()
deleting.hide() deleting.hide()
) )
secondary: [ secondary: [
text: gettext('Cancel') text: gettext('Cancel')
click: (view) -> click: (view) ->
view.hide() view.hide()
] ]
confirm.show() confirm.show()
if (!window.CmsUtils) window.CmsUtils = {}; require(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js/views/feedback_prompt",
"jquery.ui", "jquery.timepicker", "jquery.leanModal", "jquery.form"],
function($, _, gettext, NotificationView, PromptView) {
var $body; var $body;
var $modal; var $modal;
...@@ -13,13 +15,8 @@ var $newComponentButton; ...@@ -13,13 +15,8 @@ var $newComponentButton;
$(document).ready(function() { $(document).ready(function() {
$body = $('body'); $body = $('body');
$modal = $('.history-modal'); $modal = $('.history-modal');
$modalCover = $('<div class="modal-cover">'); $modalCover = $('.modal-cover');
// cdodge: this looks funny, but on AWS instances, this base.js get's wrapped in a separate scope as part of Django static
// pipelining (note, this doesn't happen on local runtimes). So if we set it on window, when we can access it from other
// scopes (namely the course-info tab)
window.$modalCover = $modalCover;
$body.append($modalCover);
$newComponentItem = $('.new-component-item'); $newComponentItem = $('.new-component-item');
$newComponentTypePicker = $('.new-component'); $newComponentTypePicker = $('.new-component');
$newComponentTemplatePickers = $('.new-component-templates'); $newComponentTemplatePickers = $('.new-component-templates');
...@@ -95,7 +92,7 @@ $(document).ready(function() { ...@@ -95,7 +92,7 @@ $(document).ready(function() {
$('a[rel*="view"][href^="#"]').bind('click', smoothScrollLink); $('a[rel*="view"][href^="#"]').bind('click', smoothScrollLink);
// tender feedback window scrolling // tender feedback window scrolling
$('a.show-tender').bind('click', window.CmsUtils.smoothScrollTop); $('a.show-tender').bind('click', smoothScrollTop);
// toggling footer additional support // toggling footer additional support
$('.cta-show-sock').bind('click', toggleSock); $('.cta-show-sock').bind('click', toggleSock);
...@@ -169,10 +166,7 @@ function smoothScrollLink(e) { ...@@ -169,10 +166,7 @@ function smoothScrollLink(e) {
}); });
} }
// On AWS instances, this base.js gets wrapped in a separate scope as part of Django static function smoothScrollTop(e) {
// pipelining (note, this doesn't happen on local runtimes). So if we set it on window,
// when we can access it from other scopes (namely Course Advanced Settings).
window.CmsUtils.smoothScrollTop = function(e) {
(e).preventDefault(); (e).preventDefault();
$.smoothScroll({ $.smoothScroll({
...@@ -189,11 +183,6 @@ function linkNewWindow(e) { ...@@ -189,11 +183,6 @@ function linkNewWindow(e) {
e.preventDefault(); e.preventDefault();
} }
// On AWS instances, base.js gets wrapped in a separate scope as part of Django static
// pipelining (note, this doesn't happen on local runtimes). So if we set it on window,
// when we can access it from other scopes (namely the checklists)
window.cmsLinkNewWindow = linkNewWindow;
function toggleSections(e) { function toggleSections(e) {
e.preventDefault(); e.preventDefault();
...@@ -378,7 +367,7 @@ function deleteSection(e) { ...@@ -378,7 +367,7 @@ function deleteSection(e) {
} }
function _deleteItem($el, type) { function _deleteItem($el, type) {
var confirm = new CMS.Views.Prompt.Warning({ var confirm = new PromptView.Warning({
title: gettext('Delete this ' + type + '?'), title: gettext('Delete this ' + type + '?'),
message: gettext('Deleting this ' + type + ' is permanent and cannot be undone.'), message: gettext('Deleting this ' + type + ' is permanent and cannot be undone.'),
actions: { actions: {
...@@ -394,7 +383,7 @@ function _deleteItem($el, type) { ...@@ -394,7 +383,7 @@ function _deleteItem($el, type) {
'id': id 'id': id
}); });
var deleting = new CMS.Views.Notification.Mini({ var deleting = new NotificationView.Mini({
title: gettext('Deleting&hellip;') title: gettext('Deleting&hellip;')
}); });
deleting.show(); deleting.show();
...@@ -429,7 +418,7 @@ function hideModal(e) { ...@@ -429,7 +418,7 @@ function hideModal(e) {
// of the editor. Users must press Cancel or Save to exit the editor. // of the editor. Users must press Cancel or Save to exit the editor.
// module_edit adds and removes the "is-fixed" class. // module_edit adds and removes the "is-fixed" class.
if (!$modalCover.hasClass("is-fixed")) { if (!$modalCover.hasClass("is-fixed")) {
$modal.hide(); $(".modal, .edit-subsection-publish-settings").hide();
$modalCover.hide(); $modalCover.hide();
} }
} }
...@@ -833,7 +822,7 @@ function saveSetSectionScheduleDate(e) { ...@@ -833,7 +822,7 @@ function saveSetSectionScheduleDate(e) {
'start': datetime 'start': datetime
}); });
var saving = new CMS.Views.Notification.Mini({ var saving = new NotificationView.Mini({
title: gettext("Saving&hellip;") title: gettext("Saving&hellip;")
}); });
saving.show(); saving.show();
...@@ -874,3 +863,5 @@ function saveSetSectionScheduleDate(e) { ...@@ -874,3 +863,5 @@ function saveSetSectionScheduleDate(e) {
saving.hide(); saving.hide();
}); });
} }
}); // end require()
define(["backbone", "js/models/asset"], function(Backbone, AssetModel){
var AssetCollection = Backbone.Collection.extend({
model : AssetModel
});
return AssetCollection;
});
define(["backbone", "js/models/chapter"], function(Backbone, ChapterModel) {
var ChapterCollection = Backbone.Collection.extend({
model: ChapterModel,
comparator: "order",
nextOrder: function() {
if(!this.length) return 1;
return this.last().get('order') + 1;
},
isEmpty: function() {
return this.length === 0 || this.every(function(m) { return m.isEmpty(); });
}
});
return ChapterCollection;
});
// Model for checklists_view.js. define(["backbone", "underscore", "js/models/checklist"],
CMS.Models.Checklist = Backbone.Model.extend({ function(Backbone, _, ChecklistModel) {
}); var ChecklistCollection = Backbone.Collection.extend({
model : ChecklistModel,
CMS.Models.ChecklistCollection = Backbone.Collection.extend({
model : CMS.Models.Checklist,
parse: function(response) { parse: function(response) {
_.each(response, _.each(response,
function( element, idx ) { function( element, idx ) {
element.id = idx; element.id = idx;
}); });
return response; return response;
}, },
// Disable caching so the browser back button will work (checklists have links to other // Disable caching so the browser back button will work (checklists have links to other
// places within Studio). // places within Studio).
fetch: function (options) { fetch: function (options) {
options.cache = false; options.cache = false;
return Backbone.Collection.prototype.fetch.call(this, options); return Backbone.Collection.prototype.fetch.call(this, options);
} }
});
return ChecklistCollection;
}); });
define(["backbone", "js/models/settings/course_grader"], function(Backbone, CourseGrader) {
var CourseGraderCollection = Backbone.Collection.extend({
model : CourseGrader,
course_location : null, // must be set to a Location object
url : function() {
return '/' + this.course_location.get('org') + "/" + this.course_location.get('course') + '/settings-grading/' + this.course_location.get('name') + '/';
},
sumWeights : function() {
return this.reduce(function(subtotal, grader) { return subtotal + grader.get('weight'); }, 0);
}
});
return CourseGraderCollection;
}); // end define()
define(["backbone", "js/models/course_relative"], function(Backbone, CourseRelativeModel) {
var CourseRelativeCollection = Backbone.Collection.extend({
model: CourseRelativeModel
});
return CourseRelativeCollection;
});
define(["backbone", "js/models/course_update"], function(Backbone, CourseUpdateModel) {
/*
The intitializer of this collection must set id to the update's location.url and courseLocation to the course's location. Must pass the
collection of updates as [{ date : "month day", content : "html"}]
*/
var CourseUpdateCollection = Backbone.Collection.extend({
url : function() {return this.urlbase + "course_info/updates/";},
model : CourseUpdateModel
});
return CourseUpdateCollection;
});
define(["backbone", "js/models/metadata"], function(Backbone, MetadataModel) {
var MetadataCollection = Backbone.Collection.extend({
model : MetadataModel,
comparator: "display_name"
});
return MetadataCollection;
});
define(["backbone", "js/models/textbook"],
function(Backbone, TextbookModel) {
var TextbookCollection = Backbone.Collection.extend({
model: TextbookModel,
url: function() { return CMS.URL.TEXTBOOKS; },
save: function(options) {
return this.sync('update', this, options);
}
});
return TextbookCollection;
});
...@@ -12,37 +12,41 @@ ...@@ -12,37 +12,41 @@
* NOTE: if something outside of this wants to cancel the event, invoke cachedhesitation.untrigger(null | anything); * NOTE: if something outside of this wants to cancel the event, invoke cachedhesitation.untrigger(null | anything);
*/ */
CMS.HesitateEvent = function(executeOnTimeOut, cancelSelector, onlyOnce) { define(["jquery"], function($) {
this.executeOnTimeOut = executeOnTimeOut; var HesitateEvent = function(executeOnTimeOut, cancelSelector, onlyOnce) {
this.cancelSelector = cancelSelector; this.executeOnTimeOut = executeOnTimeOut;
this.timeoutEventId = null; this.cancelSelector = cancelSelector;
this.originalEvent = null; this.timeoutEventId = null;
this.onlyOnce = (onlyOnce === true); this.originalEvent = null;
}; this.onlyOnce = (onlyOnce === true);
};
CMS.HesitateEvent.DURATION = 800; HesitateEvent.DURATION = 800;
CMS.HesitateEvent.prototype.trigger = function(event) { HesitateEvent.prototype.trigger = function(event) {
if (event.data.timeoutEventId == null) { if (event.data.timeoutEventId == null) {
event.data.timeoutEventId = window.setTimeout( event.data.timeoutEventId = window.setTimeout(
function() { event.data.fireEvent(event); }, function() { event.data.fireEvent(event); },
CMS.HesitateEvent.DURATION); HesitateEvent.DURATION);
event.data.originalEvent = event; event.data.originalEvent = event;
$(event.data.originalEvent.delegateTarget).on(event.data.cancelSelector, event.data, event.data.untrigger); $(event.data.originalEvent.delegateTarget).on(event.data.cancelSelector, event.data, event.data.untrigger);
} }
}; };
CMS.HesitateEvent.prototype.fireEvent = function(event) { HesitateEvent.prototype.fireEvent = function(event) {
event.data.timeoutEventId = null; event.data.timeoutEventId = null;
$(event.data.originalEvent.delegateTarget).off(event.data.cancelSelector, event.data.untrigger); $(event.data.originalEvent.delegateTarget).off(event.data.cancelSelector, event.data.untrigger);
if (event.data.onlyOnce) $(event.data.originalEvent.delegateTarget).off(event.data.originalEvent.type, event.data.trigger); if (event.data.onlyOnce) $(event.data.originalEvent.delegateTarget).off(event.data.originalEvent.type, event.data.trigger);
event.data.executeOnTimeOut(event.data.originalEvent); event.data.executeOnTimeOut(event.data.originalEvent);
}; };
CMS.HesitateEvent.prototype.untrigger = function(event) { HesitateEvent.prototype.untrigger = function(event) {
if (event.data.timeoutEventId) { if (event.data.timeoutEventId) {
window.clearTimeout(event.data.timeoutEventId); window.clearTimeout(event.data.timeoutEventId);
$(event.data.originalEvent.delegateTarget).off(event.data.cancelSelector, event.data.untrigger); $(event.data.originalEvent.delegateTarget).off(event.data.cancelSelector, event.data.untrigger);
} }
event.data.timeoutEventId = null; event.data.timeoutEventId = null;
}; };
return HesitateEvent;
});
/** define(["backbone"], function(Backbone) {
* Simple model for an asset. /**
*/ * Simple model for an asset.
CMS.Models.Asset = Backbone.Model.extend({ */
var Asset = Backbone.Model.extend({
defaults: { defaults: {
display_name: "", display_name: "",
thumbnail: "", thumbnail: "",
date_added: "", date_added: "",
url: "", url: "",
portable_url: "", portable_url: "",
locked: false locked: false
} }
});
return Asset;
}); });
CMS.Models.AssetCollection = Backbone.Collection.extend({
model : CMS.Models.Asset
});
define(["backbone", "underscore", "js/models/location"], function(Backbone, _, Location) {
var AssignmentGrade = Backbone.Model.extend({
defaults : {
graderType : null, // the type label (string). May be "Not Graded" which implies None. I'd like to use id but that's ephemeral
location : null // A location object
},
initialize : function(attrs) {
if (attrs['assignmentUrl']) {
this.set('location', new Location(attrs['assignmentUrl'], {parse: true}));
}
},
parse : function(attrs) {
if (attrs && attrs['location']) {
attrs.location = new Location(attrs['location'], {parse: true});
}
},
urlRoot : function() {
if (this.has('location')) {
var location = this.get('location');
return '/' + location.get('org') + "/" + location.get('course') + '/' + location.get('category') + '/'
+ location.get('name') + '/gradeas/';
}
else return "";
}
});
return AssignmentGrade;
});
define(["backbone", "backbone.associations"], function(Backbone) {
var Chapter = Backbone.AssociatedModel.extend({
defaults: function() {
return {
name: "",
asset_path: "",
order: this.collection ? this.collection.nextOrder() : 1
};
},
isEmpty: function() {
return !this.get('name') && !this.get('asset_path');
},
parse: function(response) {
if("title" in response && !("name" in response)) {
response.name = response.title;
delete response.title;
}
if("url" in response && !("asset_path" in response)) {
response.asset_path = response.url;
delete response.url;
}
return response;
},
toJSON: function() {
return {
title: this.get('name'),
url: this.get('asset_path')
};
},
// NOTE: validation functions should return non-internationalized error
// messages. The messages will be passed through gettext in the template.
validate: function(attrs, options) {
if(!attrs.name && !attrs.asset_path) {
return {
message: "Chapter name and asset_path are both required",
attributes: {name: true, asset_path: true}
};
} else if(!attrs.name) {
return {
message: "Chapter name is required",
attributes: {name: true}
};
} else if (!attrs.asset_path) {
return {
message: "asset_path is required",
attributes: {asset_path: true}
};
}
}
});
return Chapter;
});
define(["backbone"], function(Backbone) {
var Checklist = Backbone.Model.extend({
});
return Checklist;
});
CMS.Models.Course = Backbone.Model.extend({ define(['backbone'], function(Backbone){
defaults: { var Course = Backbone.Model.extend({
"name": "" defaults: {
}, "name": ""
validate: function(attrs, options) { },
if (!attrs.name) { validate: function(attrs, options) {
return gettext("You must specify a name"); if (!attrs.name) {
return gettext("You must specify a name");
}
} }
} });
return Course;
}); });
// single per course holds the updates and handouts define(["backbone"], function(Backbone) {
CMS.Models.CourseInfo = Backbone.Model.extend({ // single per course holds the updates and handouts
// This model class is not suited for restful operations and is considered just a server side initialized container var CourseInfo = Backbone.Model.extend({
url: '', // This model class is not suited for restful operations and is considered just a server side initialized container
url: '',
defaults: {
"courseId": "", // the location url defaults: {
"updates" : null, // UpdateCollection "courseId": "", // the location url
"handouts": null // HandoutCollection "updates" : null, // UpdateCollection
}, "handouts": null // HandoutCollection
},
idAttribute : "courseId"
}); idAttribute : "courseId"
});
// course update -- biggest kludge here is the lack of a real id to map updates to originals return CourseInfo;
CMS.Models.CourseUpdate = Backbone.Model.extend({
defaults: {
"date" : $.datepicker.formatDate('MM d, yy', new Date()),
"content" : ""
}
});
/*
The intitializer of this collection must set id to the update's location.url and courseLocation to the course's location. Must pass the
collection of updates as [{ date : "month day", content : "html"}]
*/
CMS.Models.CourseUpdateCollection = Backbone.Collection.extend({
url : function() {return this.urlbase + "course_info/updates/";},
model : CMS.Models.CourseUpdate
}); });
CMS.Models.Location = Backbone.Model.extend({ define(["backbone"], function(Backbone) {
defaults: { var CourseRelative = Backbone.Model.extend({
tag: "", defaults: {
org: "", course_location : null, // must never be null, but here to doc the field
course: "", idx : null // the index making it unique in the containing collection (no implied sort)
category: "", }
name: "" });
}, return CourseRelative;
toUrl: function(overrides) {
return
(overrides && overrides['tag'] ? overrides['tag'] : this.get('tag')) + "://" +
(overrides && overrides['org'] ? overrides['org'] : this.get('org')) + "/" +
(overrides && overrides['course'] ? overrides['course'] : this.get('course')) + "/" +
(overrides && overrides['category'] ? overrides['category'] : this.get('category')) + "/" +
(overrides && overrides['name'] ? overrides['name'] : this.get('name')) + "/";
},
_tagPattern : /[^:]+/g,
_fieldPattern : new RegExp('[^/]+','g'),
parse: function(payload) {
if (_.isArray(payload)) {
return {
tag: payload[0],
org: payload[1],
course: payload[2],
category: payload[3],
name: payload[4]
};
}
else if (_.isString(payload)) {
this._tagPattern.lastIndex = 0; // odd regex behavior requires this to be reset sometimes
var foundTag = this._tagPattern.exec(payload);
if (foundTag) {
this._fieldPattern.lastIndex = this._tagPattern.lastIndex + 1; // skip over the colon
return {
tag: foundTag[0],
org: this.getNextField(payload),
course: this.getNextField(payload),
category: this.getNextField(payload),
name: this.getNextField(payload)
}
}
else return null;
}
else {
return payload;
}
},
getNextField : function(payload) {
try {
return this._fieldPattern.exec(payload)[0];
}
catch (err) {
return "";
}
}
});
CMS.Models.CourseRelative = Backbone.Model.extend({
defaults: {
course_location : null, // must never be null, but here to doc the field
idx : null // the index making it unique in the containing collection (no implied sort)
}
});
CMS.Models.CourseRelativeCollection = Backbone.Collection.extend({
model : CMS.Models.CourseRelative
}); });
define(["backbone", "jquery", "jquery.ui"], function(Backbone, $) {
// course update -- biggest kludge here is the lack of a real id to map updates to originals
var CourseUpdate = Backbone.Model.extend({
defaults: {
"date" : $.datepicker.formatDate('MM d, yy', new Date()),
"content" : ""
}
});
return CourseUpdate;
}); // end define()
define(["backbone", "underscore"], function(Backbone, _) {
var Location = Backbone.Model.extend({
defaults: {
tag: "",
org: "",
course: "",
category: "",
name: ""
},
toUrl: function(overrides) {
return
(overrides && overrides['tag'] ? overrides['tag'] : this.get('tag')) + "://" +
(overrides && overrides['org'] ? overrides['org'] : this.get('org')) + "/" +
(overrides && overrides['course'] ? overrides['course'] : this.get('course')) + "/" +
(overrides && overrides['category'] ? overrides['category'] : this.get('category')) + "/" +
(overrides && overrides['name'] ? overrides['name'] : this.get('name')) + "/";
},
_tagPattern : /[^:]+/g,
_fieldPattern : new RegExp('[^/]+','g'),
parse: function(payload) {
if (_.isArray(payload)) {
return {
tag: payload[0],
org: payload[1],
course: payload[2],
category: payload[3],
name: payload[4]
};
}
else if (_.isString(payload)) {
this._tagPattern.lastIndex = 0; // odd regex behavior requires this to be reset sometimes
var foundTag = this._tagPattern.exec(payload);
if (foundTag) {
this._fieldPattern.lastIndex = this._tagPattern.lastIndex + 1; // skip over the colon
return {
tag: foundTag[0],
org: this.getNextField(payload),
course: this.getNextField(payload),
category: this.getNextField(payload),
name: this.getNextField(payload)
};
}
else return null;
}
else {
return payload;
}
},
getNextField : function(payload) {
try {
return this._fieldPattern.exec(payload)[0];
}
catch (err) {
return "";
}
}
});
return Location;
});
/** define(["backbone"], function(Backbone) {
* Model used for metadata setting editors. This model does not do its own saving, /**
* as that is done by module_edit.coffee. * Model used for metadata setting editors. This model does not do its own saving,
*/ * as that is done by module_edit.coffee.
CMS.Models.Metadata = Backbone.Model.extend({ */
var Metadata = Backbone.Model.extend({
defaults: {
"field_name": null,
"display_name": null,
"value" : null,
"explicitly_set": null,
"default_value" : null,
"options" : null,
"type" : null
},
defaults: { initialize: function() {
"field_name": null, this.original_value = this.get('value');
"display_name": null, this.original_explicitly_set = this.get('explicitly_set');
"value" : null, },
"explicitly_set": null,
"default_value" : null,
"options" : null,
"type" : null
},
initialize: function() { /**
this.original_value = this.get('value'); * Returns true if the stored value is different, or if the "explicitly_set"
this.original_explicitly_set = this.get('explicitly_set'); * property has changed.
}, */
isModified : function() {
if (!this.get('explicitly_set') && !this.original_explicitly_set) {
return false;
}
if (this.get('explicitly_set') && this.original_explicitly_set) {
return this.get('value') !== this.original_value;
}
return true;
},
/** /**
* Returns true if the stored value is different, or if the "explicitly_set" * Returns true if a non-default/non-inherited value has been set.
* property has changed. */
*/ isExplicitlySet: function() {
isModified : function() { return this.get('explicitly_set');
if (!this.get('explicitly_set') && !this.original_explicitly_set) { },
return false;
}
if (this.get('explicitly_set') && this.original_explicitly_set) {
return this.get('value') !== this.original_value;
}
return true;
},
/** /**
* Returns true if a non-default/non-inherited value has been set. * The value, as shown in the UI. This may be an inherited or default value.
*/ */
isExplicitlySet: function() { getDisplayValue : function () {
return this.get('explicitly_set'); return this.get('value');
}, },
/** /**
* The value, as shown in the UI. This may be an inherited or default value. * The value, as should be returned to the server. if 'isExplicitlySet'
*/ * returns false, this method returns null to indicate that the value
getDisplayValue : function () { * is not set at this level.
return this.get('value'); */
}, getValue: function() {
return this.get('explicitly_set') ? this.get('value') : null;
},
/** /**
* The value, as should be returned to the server. if 'isExplicitlySet' * Sets the displayed value.
* returns false, this method returns null to indicate that the value */
* is not set at this level. setValue: function (value) {
*/ this.set({
getValue: function() { explicitly_set: true,
return this.get('explicitly_set') ? this.get('value') : null; value: value
}, });
},
/** /**
* Sets the displayed value. * Returns the field name, which should be used for persisting the metadata
*/ * field to the server.
setValue: function (value) { */
this.set({ getFieldName: function () {
explicitly_set: true, return this.get('field_name');
value: value },
});
},
/** /**
* Returns the field name, which should be used for persisting the metadata * Returns the options. This may be a array of possible values, or an object
* field to the server. * with properties like "max", "min" and "step".
*/ */
getFieldName: function () { getOptions: function () {
return this.get('field_name'); return this.get('options');
}, },
/** /**
* Returns the options. This may be a array of possible values, or an object * Returns the type of this metadata field. Possible values are SELECT_TYPE,
* with properties like "max", "min" and "step". * INTEGER_TYPE, and FLOAT_TYPE, GENERIC_TYPE.
*/ */
getOptions: function () { getType: function() {
return this.get('options'); return this.get('type');
}, },
/** /**
* Returns the type of this metadata field. Possible values are SELECT_TYPE, * Reverts the value to the default_value specified at construction, and updates the
* INTEGER_TYPE, and FLOAT_TYPE, GENERIC_TYPE. * explicitly_set property.
*/ */
getType: function() { clear: function() {
return this.get('type'); this.set({
}, explicitly_set: false,
value: this.get('default_value')
});
}
});
/** Metadata.SELECT_TYPE = "Select";
* Reverts the value to the default_value specified at construction, and updates the Metadata.INTEGER_TYPE = "Integer";
* explicitly_set property. Metadata.FLOAT_TYPE = "Float";
*/ Metadata.GENERIC_TYPE = "Generic";
clear: function() { Metadata.LIST_TYPE = "List";
this.set({
explicitly_set: false,
value: this.get('default_value')
});
}
});
CMS.Models.MetadataCollection = Backbone.Collection.extend({ return Metadata;
model : CMS.Models.Metadata,
comparator: "display_name"
}); });
CMS.Models.Metadata.SELECT_TYPE = "Select";
CMS.Models.Metadata.INTEGER_TYPE = "Integer";
CMS.Models.Metadata.FLOAT_TYPE = "Float";
CMS.Models.Metadata.GENERIC_TYPE = "Generic";
CMS.Models.Metadata.LIST_TYPE = "List";
CMS.Models.ModuleInfo = Backbone.Model.extend({ define(["backbone"], function(Backbone) {
url: function() {return "/module_info/" + this.id;}, var ModuleInfo = Backbone.Model.extend({
url: function() {return "/module_info/" + this.id;},
defaults: { defaults: {
"id": null, "id": null,
"data": null, "data": null,
"metadata" : null, "metadata" : null,
"children" : null "children" : null
} }
});
return ModuleInfo;
}); });
CMS.Models.Section = Backbone.Model.extend({ define(["backbone", "gettext", "js/views/feedback_notification"], function(Backbone, gettext, NotificationView) {
defaults: { var Section = Backbone.Model.extend({
"name": "" defaults: {
}, "name": ""
validate: function(attrs, options) { },
if (!attrs.name) { validate: function(attrs, options) {
return gettext("You must specify a name"); if (!attrs.name) {
} return gettext("You must specify a name");
}, }
url: "/save_item", },
toJSON: function() { url: "/save_item",
return { toJSON: function() {
id: this.get("id"), return {
metadata: { id: this.get("id"),
display_name: this.get("name") metadata: {
display_name: this.get("name")
}
};
},
initialize: function() {
this.listenTo(this, "request", this.showNotification);
this.listenTo(this, "sync", this.hideNotification);
},
showNotification: function() {
if(!this.msg) {
this.msg = new NotificationView.Mini({
title: gettext("Saving&hellip;")
});
} }
}; this.msg.show();
}, },
initialize: function() { hideNotification: function() {
this.listenTo(this, "request", this.showNotification); if(!this.msg) { return; }
this.listenTo(this, "sync", this.hideNotification); this.msg.hide();
},
showNotification: function() {
if(!this.msg) {
this.msg = new CMS.Views.Notification.Mini({
title: gettext("Saving&hellip;")
});
} }
this.msg.show(); });
}, return Section;
hideNotification: function() {
if(!this.msg) { return; }
this.msg.hide();
}
}); });
if (!CMS.Models['Settings']) CMS.Models.Settings = {}; define(["backbone"], function(Backbone) {
CMS.Models.Settings.Advanced = Backbone.Model.extend({ var Advanced = Backbone.Model.extend({
defaults: { defaults: {
// the properties are whatever the user types in (in addition to whatever comes originally from the server) // the properties are whatever the user types in (in addition to whatever comes originally from the server)
...@@ -21,3 +21,6 @@ CMS.Models.Settings.Advanced = Backbone.Model.extend({ ...@@ -21,3 +21,6 @@ CMS.Models.Settings.Advanced = Backbone.Model.extend({
Backbone.Model.prototype.save.call(this, attrs, options); Backbone.Model.prototype.save.call(this, attrs, options);
} }
}); });
return Advanced;
}); // end define()
if (!CMS.Models['Settings']) CMS.Models.Settings = new Object(); define(["backbone", "underscore", "gettext", "js/models/location"], function(Backbone, _, gettext, Location) {
CMS.Models.Settings.CourseDetails = Backbone.Model.extend({ var CourseDetails = Backbone.Model.extend({
defaults: { defaults: {
location : null, // the course's Location model, required location : null, // the course's Location model, required
start_date: null, // maps to 'start' start_date: null, // maps to 'start'
...@@ -18,7 +18,7 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({ ...@@ -18,7 +18,7 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({
// When init'g from html script, ensure you pass {parse: true} as an option (2nd arg to reset) // When init'g from html script, ensure you pass {parse: true} as an option (2nd arg to reset)
parse: function(attributes) { parse: function(attributes) {
if (attributes['course_location']) { if (attributes['course_location']) {
attributes.location = new CMS.Models.Location(attributes.course_location, {parse:true}); attributes.location = new Location(attributes.course_location, {parse:true});
} }
if (attributes['start_date']) { if (attributes['start_date']) {
attributes.start_date = new Date(attributes.start_date); attributes.start_date = new Date(attributes.start_date);
...@@ -81,3 +81,7 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({ ...@@ -81,3 +81,7 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({
else return ""; else return "";
} }
}); });
return CourseDetails;
}); // end define()
define(["backbone", "underscore", "gettext"], function(Backbone, _, gettext) {
var CourseGrader = Backbone.Model.extend({
defaults: {
"type" : "", // must be unique w/in collection (ie. w/in course)
"min_count" : 1,
"drop_count" : 0,
"short_label" : "", // what to use in place of type if space is an issue
"weight" : 0 // int 0..100
},
parse : function(attrs) {
if (attrs['weight']) {
if (!_.isNumber(attrs.weight)) attrs.weight = parseInt(attrs.weight, 10);
}
if (attrs['min_count']) {
if (!_.isNumber(attrs.min_count)) attrs.min_count = parseInt(attrs.min_count, 10);
}
if (attrs['drop_count']) {
if (!_.isNumber(attrs.drop_count)) attrs.drop_count = parseInt(attrs.drop_count, 10);
}
return attrs;
},
validate : function(attrs) {
var errors = {};
if (_.has(attrs, 'type')) {
if (_.isEmpty(attrs['type'])) {
errors.type = "The assignment type must have a name.";
}
else {
// FIXME somehow this.collection is unbound sometimes. I can't track down when
var existing = this.collection && this.collection.some(function(other) { return (other.cid != this.cid) && (other.get('type') == attrs['type']);}, this);
if (existing) {
errors.type = gettext("There's already another assignment type with this name.");
}
}
}
if (_.has(attrs, 'weight')) {
var intWeight = parseInt(attrs.weight); // see if this ensures value saved is int
if (!isFinite(intWeight) || /\D+/.test(attrs.weight) || intWeight < 0 || intWeight > 100) {
errors.weight = gettext("Please enter an integer between 0 and 100.");
}
else {
attrs.weight = intWeight;
if (this.collection && attrs.weight > 0) {
// FIXME b/c saves don't update the models if validation fails, we should
// either revert the field value to the one in the model and make them make room
// or figure out a holistic way to balance the vals across the whole
// if ((this.collection.sumWeights() + attrs.weight - this.get('weight')) > 100)
// errors.weight = "The weights cannot add to more than 100.";
}
}}
if (_.has(attrs, 'min_count')) {
if (!isFinite(attrs.min_count) || /\D+/.test(attrs.min_count)) {
errors.min_count = gettext("Please enter an integer.");
}
else attrs.min_count = parseInt(attrs.min_count, 10);
}
if (_.has(attrs, 'drop_count')) {
if (!isFinite(attrs.drop_count) || /\D+/.test(attrs.drop_count)) {
errors.drop_count = gettext("Please enter an integer.");
}
else attrs.drop_count = parseInt(attrs.drop_count, 10);
}
if (_.has(attrs, 'min_count') && _.has(attrs, 'drop_count') && attrs.drop_count > attrs.min_count) {
errors.drop_count = _.template(
gettext("Cannot drop more <% attrs.types %> than will assigned."),
attrs, {variable: 'attrs'});
}
if (!_.isEmpty(errors)) return errors;
}
});
return CourseGrader;
}); // end define()
if (!CMS.Models['Settings']) CMS.Models.Settings = new Object(); define(["backbone", "js/models/location", "js/collections/course_grader"],
function(Backbone, Location, CourseGraderCollection) {
CMS.Models.Settings.CourseGradingPolicy = Backbone.Model.extend({ var CourseGradingPolicy = Backbone.Model.extend({
defaults : { defaults : {
course_location : null, course_location : null,
graders : null, // CourseGraderCollection graders : null, // CourseGraderCollection
...@@ -9,7 +10,7 @@ CMS.Models.Settings.CourseGradingPolicy = Backbone.Model.extend({ ...@@ -9,7 +10,7 @@ CMS.Models.Settings.CourseGradingPolicy = Backbone.Model.extend({
}, },
parse: function(attributes) { parse: function(attributes) {
if (attributes['course_location']) { if (attributes['course_location']) {
attributes.course_location = new CMS.Models.Location(attributes.course_location, {parse:true}); attributes.course_location = new Location(attributes.course_location, {parse:true});
} }
if (attributes['graders']) { if (attributes['graders']) {
var graderCollection; var graderCollection;
...@@ -19,7 +20,7 @@ CMS.Models.Settings.CourseGradingPolicy = Backbone.Model.extend({ ...@@ -19,7 +20,7 @@ CMS.Models.Settings.CourseGradingPolicy = Backbone.Model.extend({
graderCollection.reset(attributes.graders); graderCollection.reset(attributes.graders);
} }
else { else {
graderCollection = new CMS.Models.Settings.CourseGraderCollection(attributes.graders); graderCollection = new CourseGraderCollection(attributes.graders);
graderCollection.course_location = attributes['course_location'] || this.get('course_location'); graderCollection.course_location = attributes['course_location'] || this.get('course_location');
} }
attributes.graders = graderCollection; attributes.graders = graderCollection;
...@@ -74,83 +75,5 @@ CMS.Models.Settings.CourseGradingPolicy = Backbone.Model.extend({ ...@@ -74,83 +75,5 @@ CMS.Models.Settings.CourseGradingPolicy = Backbone.Model.extend({
} }
}); });
CMS.Models.Settings.CourseGrader = Backbone.Model.extend({ return CourseGradingPolicy;
defaults: { }); // end define()
"type" : "", // must be unique w/in collection (ie. w/in course)
"min_count" : 1,
"drop_count" : 0,
"short_label" : "", // what to use in place of type if space is an issue
"weight" : 0 // int 0..100
},
parse : function(attrs) {
if (attrs['weight']) {
if (!_.isNumber(attrs.weight)) attrs.weight = parseInt(attrs.weight);
}
if (attrs['min_count']) {
if (!_.isNumber(attrs.min_count)) attrs.min_count = parseInt(attrs.min_count);
}
if (attrs['drop_count']) {
if (!_.isNumber(attrs.drop_count)) attrs.drop_count = parseInt(attrs.drop_count);
}
return attrs;
},
validate : function(attrs) {
var errors = {};
if (_.has(attrs, 'type')) {
if (_.isEmpty(attrs['type'])) {
errors.type = "The assignment type must have a name.";
}
else {
// FIXME somehow this.collection is unbound sometimes. I can't track down when
var existing = this.collection && this.collection.some(function(other) { return (other.cid != this.cid) && (other.get('type') == attrs['type']);}, this);
if (existing) {
errors.type = gettext("There's already another assignment type with this name.");
}
}
}
if (_.has(attrs, 'weight')) {
var intWeight = parseInt(attrs.weight); // see if this ensures value saved is int
if (!isFinite(intWeight) || /\D+/.test(attrs.weight) || intWeight < 0 || intWeight > 100) {
errors.weight = gettext("Please enter an integer between 0 and 100.");
}
else {
attrs.weight = intWeight;
if (this.collection && attrs.weight > 0) {
// FIXME b/c saves don't update the models if validation fails, we should
// either revert the field value to the one in the model and make them make room
// or figure out a wholistic way to balance the vals across the whole
// if ((this.collection.sumWeights() + attrs.weight - this.get('weight')) > 100)
// errors.weight = "The weights cannot add to more than 100.";
}
}}
if (_.has(attrs, 'min_count')) {
if (!isFinite(attrs.min_count) || /\D+/.test(attrs.min_count)) {
errors.min_count = gettext("Please enter an integer.");
}
else attrs.min_count = parseInt(attrs.min_count);
}
if (_.has(attrs, 'drop_count')) {
if (!isFinite(attrs.drop_count) || /\D+/.test(attrs.drop_count)) {
errors.drop_count = gettext("Please enter an integer.");
}
else attrs.drop_count = parseInt(attrs.drop_count);
}
if (_.has(attrs, 'min_count') && _.has(attrs, 'drop_count') && attrs.drop_count > attrs.min_count) {
errors.drop_count = _.template(
gettext("Cannot drop more <% attrs.types %> than will assigned."),
attrs, {variable: 'attrs'});
}
if (!_.isEmpty(errors)) return errors;
}
});
CMS.Models.Settings.CourseGraderCollection = Backbone.Collection.extend({
model : CMS.Models.Settings.CourseGrader,
course_location : null, // must be set to a Location object
url : function() {
return '/' + this.course_location.get('org') + "/" + this.course_location.get('course') + '/settings-grading/' + this.course_location.get('name') + '/';
},
sumWeights : function() {
return this.reduce(function(subtotal, grader) { return subtotal + grader.get('weight'); }, 0);
}
});
CMS.Models.Textbook = Backbone.AssociatedModel.extend({ define(["backbone", "underscore", "js/models/chapter", "js/collections/chapter", "backbone.associations"],
defaults: function() { function(Backbone, _, ChapterModel, ChapterCollection) {
return {
name: "", var Textbook = Backbone.AssociatedModel.extend({
chapters: new CMS.Collections.ChapterSet([{}]), defaults: function() {
showChapters: false,
editing: false
};
},
relations: [{
type: Backbone.Many,
key: "chapters",
relatedModel: "CMS.Models.Chapter",
collectionType: "CMS.Collections.ChapterSet"
}],
initialize: function() {
this.setOriginalAttributes();
return this;
},
setOriginalAttributes: function() {
this._originalAttributes = this.parse(this.toJSON());
},
reset: function() {
this.set(this._originalAttributes, {parse: true});
},
isDirty: function() {
return !_.isEqual(this._originalAttributes, this.parse(this.toJSON()));
},
isEmpty: function() {
return !this.get('name') && this.get('chapters').isEmpty();
},
url: function() {
if(this.isNew()) {
return CMS.URL.TEXTBOOKS + "/new";
} else {
return CMS.URL.TEXTBOOKS + "/" + this.id;
}
},
parse: function(response) {
var ret = $.extend(true, {}, response);
if("tab_title" in ret && !("name" in ret)) {
ret.name = ret.tab_title;
delete ret.tab_title;
}
if("url" in ret && !("chapters" in ret)) {
ret.chapters = {"url": ret.url};
delete ret.url;
}
_.each(ret.chapters, function(chapter, i) {
chapter.order = chapter.order || i+1;
});
return ret;
},
toJSON: function() {
return {
tab_title: this.get('name'),
chapters: this.get('chapters').toJSON()
};
},
// NOTE: validation functions should return non-internationalized error
// messages. The messages will be passed through gettext in the template.
validate: function(attrs, options) {
if (!attrs.name) {
return { return {
message: "Textbook name is required", name: "",
attributes: {name: true} chapters: new ChapterCollection([{}]),
showChapters: false,
editing: false
}; };
} },
if (attrs.chapters.length === 0) { relations: [{
type: Backbone.Many,
key: "chapters",
relatedModel: ChapterModel,
collectionType: ChapterCollection
}],
initialize: function() {
this.setOriginalAttributes();
return this;
},
setOriginalAttributes: function() {
this._originalAttributes = this.parse(this.toJSON());
},
reset: function() {
this.set(this._originalAttributes, {parse: true});
},
isDirty: function() {
return !_.isEqual(this._originalAttributes, this.parse(this.toJSON()));
},
isEmpty: function() {
return !this.get('name') && this.get('chapters').isEmpty();
},
url: function() {
if(this.isNew()) {
return CMS.URL.TEXTBOOKS + "/new";
} else {
return CMS.URL.TEXTBOOKS + "/" + this.id;
}
},
parse: function(response) {
var ret = $.extend(true, {}, response);
if("tab_title" in ret && !("name" in ret)) {
ret.name = ret.tab_title;
delete ret.tab_title;
}
if("url" in ret && !("chapters" in ret)) {
ret.chapters = {"url": ret.url};
delete ret.url;
}
_.each(ret.chapters, function(chapter, i) {
chapter.order = chapter.order || i+1;
});
return ret;
},
toJSON: function() {
return { return {
message: "Please add at least one chapter", tab_title: this.get('name'),
attributes: {chapters: true} chapters: this.get('chapters').toJSON()
}; };
} else { },
// validate all chapters // NOTE: validation functions should return non-internationalized error
var invalidChapters = []; // messages. The messages will be passed through gettext in the template.
attrs.chapters.each(function(chapter) { validate: function(attrs, options) {
if(!chapter.isValid()) { if (!attrs.name) {
invalidChapters.push(chapter);
}
});
if(!_.isEmpty(invalidChapters)) {
return { return {
message: "All chapters must have a name and asset", message: "Textbook name is required",
attributes: {chapters: invalidChapters} attributes: {name: true}
}; };
} }
if (attrs.chapters.length === 0) {
return {
message: "Please add at least one chapter",
attributes: {chapters: true}
};
} else {
// validate all chapters
var invalidChapters = [];
attrs.chapters.each(function(chapter) {
if(!chapter.isValid()) {
invalidChapters.push(chapter);
}
});
if(!_.isEmpty(invalidChapters)) {
return {
message: "All chapters must have a name and asset",
attributes: {chapters: invalidChapters}
};
}
}
} }
} });
}); return Textbook;
CMS.Collections.TextbookSet = Backbone.Collection.extend({
model: CMS.Models.Textbook,
url: function() { return CMS.URL.TEXTBOOKS; },
save: function(options) {
return this.sync('update', this, options);
}
});
CMS.Models.Chapter = Backbone.AssociatedModel.extend({
defaults: function() {
return {
name: "",
asset_path: "",
order: this.collection ? this.collection.nextOrder() : 1
};
},
isEmpty: function() {
return !this.get('name') && !this.get('asset_path');
},
parse: function(response) {
if("title" in response && !("name" in response)) {
response.name = response.title;
delete response.title;
}
if("url" in response && !("asset_path" in response)) {
response.asset_path = response.url;
delete response.url;
}
return response;
},
toJSON: function() {
return {
title: this.get('name'),
url: this.get('asset_path')
};
},
// NOTE: validation functions should return non-internationalized error
// messages. The messages will be passed through gettext in the template.
validate: function(attrs, options) {
if(!attrs.name && !attrs.asset_path) {
return {
message: "Chapter name and asset_path are both required",
attributes: {name: true, asset_path: true}
};
} else if(!attrs.name) {
return {
message: "Chapter name is required",
attributes: {name: true}
};
} else if (!attrs.asset_path) {
return {
message: "asset_path is required",
attributes: {asset_path: true}
};
}
}
});
CMS.Collections.ChapterSet = Backbone.Collection.extend({
model: CMS.Models.Chapter,
comparator: "order",
nextOrder: function() {
if(!this.length) return 1;
return this.last().get('order') + 1;
},
isEmpty: function() {
return this.length === 0 || this.every(function(m) { return m.isEmpty(); });
}
}); });
CMS.Models.FileUpload = Backbone.Model.extend({ define(["backbone", "underscore", "gettext"], function(Backbone, _, gettext) {
var FileUpload = Backbone.Model.extend({
defaults: { defaults: {
"title": "", "title": "",
"message": "", "message": "",
...@@ -57,3 +59,6 @@ CMS.Models.FileUpload = Backbone.Model.extend({ ...@@ -57,3 +59,6 @@ CMS.Models.FileUpload = Backbone.Model.extend({
}; };
} }
}); });
return FileUpload;
}); // end define()
CMS.Views.Asset = Backbone.View.extend({ define(["backbone", "underscore", "gettext", "js/views/feedback_prompt", "js/views/feedback_notification"],
initialize: function() { function(Backbone, _, gettext, PromptView, NotificationView) {
this.template = _.template($("#asset-tpl").text()); var AssetView = Backbone.View.extend({
this.listenTo(this.model, "change:locked", this.updateLockState); initialize: function() {
}, this.template = _.template($("#asset-tpl").text());
this.listenTo(this.model, "change:locked", this.updateLockState);
},
tagName: "tr",
events: {
"click .remove-asset-button": "confirmDelete",
"click .lock-checkbox": "lockAsset"
},
tagName: "tr", render: function() {
var uniqueId = _.uniqueId('lock_asset_');
this.$el.html(this.template({
display_name: this.model.get('display_name'),
thumbnail: this.model.get('thumbnail'),
date_added: this.model.get('date_added'),
url: this.model.get('url'),
portable_url: this.model.get('portable_url'),
uniqueId: uniqueId
}));
this.updateLockState();
return this;
},
events: { updateLockState: function () {
"click .remove-asset-button": "confirmDelete", var locked_class = "is-locked";
"click .lock-checkbox": "lockAsset"
},
render: function() { // Add a class of "locked" to the tr element if appropriate,
var uniqueId = _.uniqueId('lock_asset_'); // and toggle locked state of hidden checkbox.
if (this.model.get('locked')) {
this.$el.html(this.template({ this.$el.addClass(locked_class);
display_name: this.model.get('display_name'), this.$el.find('.lock-checkbox').attr('checked','checked');
thumbnail: this.model.get('thumbnail'), }
date_added: this.model.get('date_added'), else {
url: this.model.get('url'), this.$el.removeClass(locked_class);
portable_url: this.model.get('portable_url'), this.$el.find('.lock-checkbox').removeAttr('checked');
uniqueId: uniqueId})); }
},
this.updateLockState();
return this;
},
updateLockState: function () {
var locked_class = "is-locked";
// Add a class of "locked" to the tr element if appropriate, confirmDelete: function(e) {
// and toggle locked state of hidden checkbox. if(e && e.preventDefault) { e.preventDefault(); }
if (this.model.get('locked')) { var asset = this.model, collection = this.model.collection;
this.$el.addClass(locked_class); new PromptView.Warning({
this.$el.find('.lock-checkbox').attr('checked','checked'); title: gettext("Delete File Confirmation"),
} message: gettext("Are you sure you wish to delete this item. It cannot be reversed!\n\nAlso any content that links/refers to this item will no longer work (e.g. broken images and/or links)"),
else { actions: {
this.$el.removeClass(locked_class); primary: {
this.$el.find('.lock-checkbox').removeAttr('checked'); text: gettext("Delete"),
click: function (view) {
view.hide();
asset.destroy({
wait: true, // Don't remove the asset from the collection until successful.
success: function () {
new NotificationView.Confirmation({
title: gettext("Your file has been deleted."),
closeIcon: false,
maxShown: 2000
}).show();
}
});
}
},
secondary: {
text: gettext("Cancel"),
click: function (view) {
view.hide();
}
} }
}, }
}).show();
},
confirmDelete: function(e) { lockAsset: function(e) {
if(e && e.preventDefault) { e.preventDefault(); } var asset = this.model;
var asset = this.model; var saving = new NotificationView.Mini({
new CMS.Views.Prompt.Warning({ title: gettext("Saving&hellip;")
title: gettext("Delete File Confirmation"), }).show();
message: gettext("Are you sure you wish to delete this item. It cannot be reversed!\n\nAlso any content that links/refers to this item will no longer work (e.g. broken images and/or links)"), asset.save({'locked': !asset.get('locked')}, {
actions: { wait: true, // This means we won't re-render until we get back the success state.
primary: { success: function() {
text: gettext("Delete"), saving.hide();
click: function (view) { }
view.hide(); });
asset.destroy({
wait: true, // Don't remove the asset from the collection until successful.
success: function () {
new CMS.Views.Notification.Confirmation({
title: gettext("Your file has been deleted."),
closeIcon: false,
maxShown: 2000
}).show()
}
}
);
}
},
secondary: [
{
text: gettext("Cancel"),
click: function (view) {
view.hide();
}
}
]
}
}).show();
},
lockAsset: function(e) {
var asset = this.model;
var saving = new CMS.Views.Notification.Mini({
title: gettext("Saving&hellip;")
}).show();
asset.save({'locked': !asset.get('locked')}, {
wait: true, // This means we won't re-render until we get back the success state.
success: function() {
saving.hide();
}
});
} }
}); });
return AssetView;
}); // end define()
// This code is temporarily moved out of asset_index.html define(["backbone", "js/views/asset"], function(Backbone, AssetView) {
// to fix AWS pipelining issues. We can move it back after RequireJS is integrated.
$(document).ready(function() {
$('.uploads .upload-button').bind('click', showUploadModal);
$('.upload-modal .close-button').bind('click', hideModal);
$('.upload-modal .choose-file-button').bind('click', showFileSelectionMenu);
});
var showUploadModal = function (e) {
e.preventDefault();
resetUploadModal();
// $modal has to be global for hideModal to work.
$modal = $('.upload-modal').show();
$('.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);
}
});
$modalCover.show();
};
var showFileSelectionMenu = function(e) {
e.preventDefault();
$('.file-input').click();
};
var startUpload = function (e) { var AssetsView = Backbone.View.extend({
var file = e.target.value; // takes AssetCollection as model
$('.upload-modal h1').html(gettext('Uploading…')); initialize : function() {
$('.upload-modal .file-name').html(file.substring(file.lastIndexOf("\\") + 1)); this.listenTo(this.collection, 'destroy', this.handleDestroy);
$('.upload-modal .choose-file-button').hide(); this.render();
$('.upload-modal .progress-bar').removeClass('loaded').show(); },
};
var resetUploadModal = function () { render: function() {
$('.file-input').unbind('change', startUpload); this.$el.empty();
// Reset modal so it no longer displays information about previously var self = this;
// completed uploads. this.collection.each(
var percentVal = '0%'; function(asset) {
$('.upload-modal .progress-fill').width(percentVal); var view = new AssetView({model: asset});
$('.upload-modal .progress-fill').html(percentVal); self.$el.append(view.render().el);
$('.upload-modal .progress-bar').hide(); });
$('.upload-modal .file-name').show();
$('.upload-modal .file-name').html('');
$('.upload-modal .choose-file-button').html(gettext('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').html(gettext('Upload New File')); return this;
$('.upload-modal .embeddable-xml-input').val(asset.portable_url); },
$('.upload-modal .embeddable').show();
$('.upload-modal .file-name').hide(); handleDestroy: function(model, collection, options) {
$('.upload-modal .progress-fill').html(resp.msg); var index = options.index;
$('.upload-modal .choose-file-button').html(gettext('Load Another File')).show(); this.$el.children().eq(index).remove();
$('.upload-modal .progress-fill').width('100%');
analytics.track('Deleted Asset', {
'course': course_location_analytics,
'id': model.get('url')
});
},
addAsset: function (model) {
// If asset is not already being shown, add it.
if (this.collection.findWhere({'url': model.get('url')}) === undefined) {
this.collection.add(model, {at: 0});
var view = new AssetView({model: model});
this.$el.prepend(view.render().el);
analytics.track('Uploaded a File', {
'course': course_location_analytics,
'asset_url': model.get('url')
});
}
}
});
// TODO remove setting on window object after RequireJS. return AssetsView;
window.assetsView.addAsset(new CMS.Models.Asset(asset)); }); // end define();
};
CMS.Views.Assets = Backbone.View.extend({
// takes CMS.Models.AssetCollection as model
initialize : function() {
this.listenTo(this.collection, 'destroy', this.handleDestroy);
this.render();
},
render: function() {
this.$el.empty();
var self = this;
this.collection.each(
function(asset) {
var view = new CMS.Views.Asset({model: asset});
self.$el.append(view.render().el);
});
return this;
},
handleDestroy: function(model, collection, options) {
var index = options.index;
this.$el.children().eq(index).remove();
analytics.track('Deleted Asset', {
'course': course_location_analytics,
'id': model.get('url')
});
},
addAsset: function (model) {
// If asset is not already being shown, add it.
if (this.collection.findWhere({'url': model.get('url')}) === undefined) {
this.collection.add(model, {at: 0});
var view = new CMS.Views.Asset({model: model});
this.$el.prepend(view.render().el);
analytics.track('Uploaded a File', {
'course': course_location_analytics,
'asset_url': model.get('url')
});
}
}
});
if (!CMS.Views['Checklists']) CMS.Views.Checklists = {}; define(["backbone", "underscore", "jquery"], function(Backbone, _, $) {
var ChecklistView = Backbone.View.extend({
// takes CMS.Models.Checklists as model
CMS.Views.Checklists = Backbone.View.extend({ events : {
// takes CMS.Models.Checklists as model 'click .course-checklist .checklist-title' : "toggleChecklist",
'click .course-checklist .task input' : "toggleTask",
'click a[rel="external"]' : "popup"
},
events : { initialize : function() {
'click .course-checklist .checklist-title' : "toggleChecklist", var self = this;
'click .course-checklist .task input' : "toggleTask", this.template = _.template($("#checklist-tpl").text());
'click a[rel="external"]' : window.cmsLinkNewWindow this.collection.fetch({
}, reset: true,
complete: function() {
self.render();
}
});
},
initialize : function() { render: function() {
this.template = _.template($("#checklist-tpl").text()); // catch potential outside call before template loaded
this.listenTo(this.collection, 'reset', this.render); if (!this.template) return this;
this.render();
},
render: function() { this.$el.empty();
this.$el.empty();
var self = this; var self = this;
_.each(this.collection.models, _.each(this.collection.models,
function(checklist, index) { function(checklist, index) {
self.$el.append(self.renderTemplate(checklist, index)); self.$el.append(self.renderTemplate(checklist, index));
}); });
return this; return this;
}, },
renderTemplate: function (checklist, index) { renderTemplate: function (checklist, index) {
var checklistItems = checklist.attributes['items']; var checklistItems = checklist.attributes['items'];
var itemsChecked = 0; var itemsChecked = 0;
_.each(checklistItems, _.each(checklistItems,
function(checklist) { function(checklist) {
if (checklist['is_checked']) { if (checklist['is_checked']) {
itemsChecked +=1; itemsChecked +=1;
} }
}); });
var percentChecked = Math.round((itemsChecked/checklistItems.length)*100); var percentChecked = Math.round((itemsChecked/checklistItems.length)*100);
return this.template({ return this.template({
checklistIndex : index, checklistIndex : index,
checklistShortDescription : checklist.attributes['short_description'], checklistShortDescription : checklist.attributes['short_description'],
items: checklistItems, items: checklistItems,
itemsChecked: itemsChecked, itemsChecked: itemsChecked,
percentChecked: percentChecked}); percentChecked: percentChecked});
}, },
toggleChecklist : function(e) { toggleChecklist : function(e) {
e.preventDefault(); e.preventDefault();
$(e.target).closest('.course-checklist').toggleClass('is-collapsed'); $(e.target).closest('.course-checklist').toggleClass('is-collapsed');
}, },
toggleTask : function (e) { toggleTask : function (e) {
var self = this; var self = this;
var completed = 'is-completed'; var completed = 'is-completed';
var $checkbox = $(e.target); var $checkbox = $(e.target);
var $task = $checkbox.closest('.task'); var $task = $checkbox.closest('.task');
$task.toggleClass(completed); $task.toggleClass(completed);
var checklist_index = $checkbox.data('checklist'); var checklist_index = $checkbox.data('checklist');
var task_index = $checkbox.data('task'); var task_index = $checkbox.data('task');
var model = this.collection.at(checklist_index); var model = this.collection.at(checklist_index);
model.attributes.items[task_index].is_checked = $task.hasClass(completed); model.attributes.items[task_index].is_checked = $task.hasClass(completed);
model.save({}, model.save({},
{ {
success : function() { success : function() {
var updatedTemplate = self.renderTemplate(model, checklist_index); var updatedTemplate = self.renderTemplate(model, checklist_index);
self.$el.find('#course-checklist'+checklist_index).first().replaceWith(updatedTemplate); self.$el.find('#course-checklist'+checklist_index).first().replaceWith(updatedTemplate);
analytics.track('Toggled a Checklist Task', { analytics.track('Toggled a Checklist Task', {
'course': course_location_analytics, 'course': course_location_analytics,
'task': model.attributes.items[task_index].short_description, 'task': model.attributes.items[task_index].short_description,
'state': model.attributes.items[task_index].is_checked 'state': model.attributes.items[task_index].is_checked
}); });
} }
}); });
} },
popup: function(e) {
e.preventDefault();
window.open($(e.target).attr('href'));
}
});
return ChecklistView;
}); });
define(["backbone", "underscore", "codemirror", "js/views/feedback_notification", "js/views/course_info_helper"],
function(Backbone, _, CodeMirror, NotificationView, CourseInfoHelper) {
var $modalCover = $(".modal-cover");
// the handouts view is dumb right now; it needs tied to a model and all that jazz
var CourseInfoHandoutsView = Backbone.View.extend({
// collection is CourseUpdateCollection
events: {
"click .save-button" : "onSave",
"click .cancel-button" : "onCancel",
"click .edit-button" : "onEdit"
},
initialize: function() {
this.template = _.template($("#course_info_handouts-tpl").text());
var self = this;
this.model.fetch({
complete: function() {
self.render();
},
reset: true
});
},
render: function () {
CourseInfoHelper.changeContentToPreview(
this.model, 'data', this.options['base_asset_url']);
this.$el.html(
$(this.template({
model: this.model
}))
);
this.$preview = this.$el.find('.handouts-content');
this.$form = this.$el.find(".edit-handouts-form");
this.$editor = this.$form.find('.handouts-content-editor');
this.$form.hide();
return this;
},
onEdit: function(event) {
var self = this;
this.$editor.val(this.$preview.html());
this.$form.show();
this.$codeMirror = CourseInfoHelper.editWithCodeMirror(
self.model, 'data', self.options['base_asset_url'], this.$editor.get(0));
$modalCover.show();
$modalCover.bind('click', function() {
self.closeEditor();
});
},
onSave: function(event) {
this.model.set('data', this.$codeMirror.getValue());
var saving = new NotificationView.Mini({
title: gettext('Saving&hellip;')
});
saving.show();
this.model.save({}, {
success: function() {
saving.hide();
}
});
this.render();
this.$form.hide();
this.closeEditor();
analytics.track('Saved Course Handouts', {
'course': course_location_analytics
});
},
onCancel: function(event) {
this.$form.hide();
this.closeEditor();
},
closeEditor: function() {
this.$form.hide();
$modalCover.unbind('click');
$modalCover.hide();
this.$form.find('.CodeMirror').remove();
this.$codeMirror = null;
}
});
return CourseInfoHandoutsView;
}); // end define()
define(["codemirror", "utility"],
function(CodeMirror) {
var editWithCodeMirror = function(model, contentName, baseAssetUrl, textArea) {
var content = rewriteStaticLinks(model.get(contentName), baseAssetUrl, '/static/');
model.set(contentName, content);
var $codeMirror = CodeMirror.fromTextArea(textArea, {
mode: "text/html",
lineNumbers: true,
lineWrapping: true
});
$codeMirror.setValue(content);
$codeMirror.clearHistory();
return $codeMirror;
};
var changeContentToPreview = function (model, contentName, baseAssetUrl) {
var content = rewriteStaticLinks(model.get(contentName), '/static/', baseAssetUrl);
model.set(contentName, content);
return content;
};
return {'editWithCodeMirror': editWithCodeMirror, 'changeContentToPreview': changeContentToPreview};
}
);
define(["backbone", "underscore", "codemirror", "js/models/course_update",
"js/views/feedback_prompt", "js/views/feedback_notification", "js/views/course_info_helper"],
function(Backbone, _, CodeMirror, CourseUpdateModel, PromptView, NotificationView, CourseInfoHelper) {
var $modalCover = $(".modal-cover");
var CourseInfoUpdateView = Backbone.View.extend({
// collection is CourseUpdateCollection
events: {
"click .new-update-button" : "onNew",
"click #course-update-view .save-button" : "onSave",
"click #course-update-view .cancel-button" : "onCancel",
"click .post-actions > .edit-button" : "onEdit",
"click .post-actions > .delete-button" : "onDelete"
},
initialize: function() {
this.template = _.template($("#course_info_update-tpl").text());
this.render();
// when the client refetches the updates as a whole, re-render them
this.listenTo(this.collection, 'reset', this.render);
},
render: function () {
// iterate over updates and create views for each using the template
var updateEle = this.$el.find("#course-update-list");
// remove and then add all children
$(updateEle).empty();
var self = this;
this.collection.each(function (update) {
try {
CourseInfoHelper.changeContentToPreview(
update, 'content', self.options['base_asset_url']);
var newEle = self.template({ updateModel : update });
$(updateEle).append(newEle);
} catch (e) {
// ignore
}
});
this.$el.find(".new-update-form").hide();
this.$el.find('.date').datepicker({ 'dateFormat': 'MM d, yy' });
return this;
},
onNew: function(event) {
event.preventDefault();
var self = this;
// create new obj, insert into collection, and render this one ele overriding the hidden attr
var newModel = new CourseUpdateModel();
this.collection.add(newModel, {at : 0});
var $newForm = $(this.template({ updateModel : newModel }));
var updateEle = this.$el.find("#course-update-list");
$(updateEle).prepend($newForm);
var $textArea = $newForm.find(".new-update-content").first();
this.$codeMirror = CodeMirror.fromTextArea($textArea.get(0), {
mode: "text/html",
lineNumbers: true,
lineWrapping: true
});
$newForm.addClass('editing');
this.$currentPost = $newForm.closest('li');
$modalCover.show();
$modalCover.bind('click', function() {
self.closeEditor(true);
});
$('.date').datepicker('destroy');
$('.date').datepicker({ 'dateFormat': 'MM d, yy' });
},
onSave: function(event) {
event.preventDefault();
var targetModel = this.eventModel(event);
targetModel.set({ date : this.dateEntry(event).val(), content : this.$codeMirror.getValue() });
// push change to display, hide the editor, submit the change
var saving = new NotificationView.Mini({
title: gettext('Saving&hellip;')
});
saving.show();
var ele = this.modelDom(event);
targetModel.save({}, {
success: function() {
saving.hide();
},
error: function() {
ele.remove();
}
});
this.closeEditor();
analytics.track('Saved Course Update', {
'course': course_location_analytics,
'date': this.dateEntry(event).val()
});
},
onCancel: function(event) {
event.preventDefault();
// change editor contents back to model values and hide the editor
$(this.editor(event)).hide();
// If the model was never created (user created a new update, then pressed Cancel),
// we wish to remove it from the DOM.
var targetModel = this.eventModel(event);
this.closeEditor(!targetModel.id);
},
onEdit: function(event) {
event.preventDefault();
var self = this;
this.$currentPost = $(event.target).closest('li');
this.$currentPost.addClass('editing');
$(this.editor(event)).show();
var $textArea = this.$currentPost.find(".new-update-content").first();
var targetModel = this.eventModel(event);
this.$codeMirror = CourseInfoHelper.editWithCodeMirror(
targetModel, 'content', self.options['base_asset_url'], $textArea.get(0));
$modalCover.show();
$modalCover.bind('click', function() {
self.closeEditor(self);
});
},
onDelete: function(event) {
event.preventDefault();
var self = this;
var targetModel = this.eventModel(event);
var confirm = new PromptView.Warning({
title: gettext('Are you sure you want to delete this update?'),
message: gettext('This action cannot be undone.'),
actions: {
primary: {
text: gettext('OK'),
click: function () {
analytics.track('Deleted Course Update', {
'course': course_location_analytics,
'date': self.dateEntry(event).val()
});
self.modelDom(event).remove();
var deleting = new NotificationView.Mini({
title: gettext('Deleting&hellip;')
});
deleting.show();
targetModel.destroy({
success: function (model, response) {
self.collection.fetch({
success: function() {
self.render();
deleting.hide();
},
reset: true
});
}
});
confirm.hide();
}
},
secondary: {
text: gettext('Cancel'),
click: function() {
confirm.hide();
}
}
}
});
confirm.show();
},
closeEditor: function(removePost) {
var targetModel = this.collection.get(this.$currentPost.attr('name'));
if(removePost) {
this.$currentPost.remove();
}
else {
// close the modal and insert the appropriate data
this.$currentPost.removeClass('editing');
this.$currentPost.find('.date-display').html(targetModel.get('date'));
this.$currentPost.find('.date').val(targetModel.get('date'));
var content = CourseInfoHelper.changeContentToPreview(
targetModel, 'content', this.options['base_asset_url']);
try {
// just in case the content causes an error (embedded js errors)
this.$currentPost.find('.update-contents').html(content);
this.$currentPost.find('.new-update-content').val(content);
} catch (e) {
// ignore but handle rest of page
}
this.$currentPost.find('form').hide();
this.$currentPost.find('.CodeMirror').remove();
}
$modalCover.unbind('click');
$modalCover.hide();
this.$codeMirror = null;
},
// Dereferencing from events to screen elements
eventModel: function(event) {
// not sure if it should be currentTarget or delegateTarget
return this.collection.get($(event.currentTarget).attr("name"));
},
modelDom: function(event) {
return $(event.currentTarget).closest("li");
},
editor: function(event) {
var li = $(event.currentTarget).closest("li");
if (li) return $(li).find("form").first();
},
dateEntry: function(event) {
var li = $(event.currentTarget).closest("li");
if (li) return $(li).find(".date").first();
},
contentEntry: function(event) {
return $(event.currentTarget).closest("li").find(".new-update-content").first();
},
dateDisplay: function(event) {
return $(event.currentTarget).closest("li").find("#date-display").first();
},
contentDisplay: function(event) {
return $(event.currentTarget).closest("li").find(".update-contents").first();
}
});
return CourseInfoUpdateView;
}); // end define()
define(["backbone", "underscore", "underscore.string", "jquery", "gettext", "js/models/uploads", "js/views/uploads"],
function(Backbone, _, str, $, gettext, FileUploadModel, UploadDialogView) {
_.str = str; // used in template
var EditChapter = Backbone.View.extend({
initialize: function() {
this.template = _.template($("#edit-chapter-tpl").text());
this.listenTo(this.model, "change", this.render);
},
tagName: "li",
className: function() {
return "field-group chapter chapter" + this.model.get('order');
},
render: function() {
this.$el.html(this.template({
name: this.model.escape('name'),
asset_path: this.model.escape('asset_path'),
order: this.model.get('order'),
error: this.model.validationError
}));
return this;
},
events: {
"change .chapter-name": "changeName",
"change .chapter-asset-path": "changeAssetPath",
"click .action-close": "removeChapter",
"click .action-upload": "openUploadDialog",
"submit": "uploadAsset"
},
changeName: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.model.set({
name: this.$(".chapter-name").val()
}, {silent: true});
return this;
},
changeAssetPath: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.model.set({
asset_path: this.$(".chapter-asset-path").val()
}, {silent: true});
return this;
},
removeChapter: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.model.collection.remove(this.model);
return this.remove();
},
openUploadDialog: function(e) {
if(e && e.preventDefault) { e.preventDefault(); }
this.model.set({
name: this.$("input.chapter-name").val(),
asset_path: this.$("input.chapter-asset-path").val()
});
var msg = new FileUploadModel({
title: _.template(gettext("Upload a new PDF to “<%= name %>”"),
{name: section.escape('name')}),
message: "Files must be in PDF format.",
mimeTypes: ['application/pdf']
});
var that = this;
var view = new UploadDialogView({
model: msg,
onSuccess: function(response) {
var options = {};
if(!that.model.get('name')) {
options.name = response.asset.displayname;
}
options.asset_path = response.asset.url;
that.model.set(options);
}
});
$(".wrapper-view").after(view.show().el);
}
});
return EditChapter;
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment