diff --git a/AUTHORS b/AUTHORS index 94963e4630..2f4d7efead 100644 --- a/AUTHORS +++ b/AUTHORS @@ -89,3 +89,4 @@ Akshay Jagadeesh Nick Parlante Marko Seric Felipe Montoya +Julia Hansbrough diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3a9eb76165..49e5841712 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,8 +5,21 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +Blades: Hovering over CC button in video player, when transcripts are hidden, +will cause them to show up. Moving the mouse from the CC button will auto hide +them. You can hover over the CC button and then move the mouse to the +transcripts which will allow you to select some video position in 1 click. + +Blades: Add possibility to use multiple LTI tools per page. + +Blades: LTI module can now load external content in a new window. + LMS: Disable data download buttons on the instructor dashboard for large courses +LMS: Ported bulk emailing to the beta instructor dashboard. + +LMS: Add monitoring of bulk email subtasks to display progress on instructor dash. + LMS: Refactor and clean student dashboard templates. LMS: Fix issue with CourseMode expiration dates @@ -22,6 +35,8 @@ Studio: Switched to loading Javascript using require.js Studio: Better feedback during the course import process +Studio: Improve drag and drop on the course overview and subsection views. + LMS: Add split testing functionality for internal use. CMS: Add edit_course_tabs management command, providing a primitive @@ -73,6 +88,11 @@ Common: Allow instructors to input complicated expressions as answers to `NumericalResponse`s. Prior to the change only numbers were allowed, now any answer from '1/3' to 'sqrt(12)*(1-1/3^2+1/5/3^2)' are valid. +Studio/LMS: Allow for 'preview' and 'published' in a single LMS instance. Use +middlware components to retain the incoming Django request and put in thread +local storage. It is recommended that all developers define a 'preview.localhost' +which maps to the same IP address as localhost in his/her HOSTS file. + LMS: Enable beta instructor dashboard. The beta dashboard is a rearchitecture of the existing instructor dashboard and is available by clicking a link at the top right of the existing dashboard. diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index 992de9301c..8a34a2faea 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -2,7 +2,7 @@ # pylint: disable=W0621 from lettuce import world, step -from nose.tools import assert_true, assert_equal, assert_in, assert_false # pylint: disable=E0611 +from nose.tools import assert_true, assert_in, assert_false # pylint: disable=E0611 from auth.authz import get_user_by_email, get_course_groupname_for_role from django.conf import settings @@ -224,14 +224,50 @@ def i_enabled_the_advanced_module(step, module): press_the_notification_button(step, 'Save') -@step('I have clicked the new unit button') -def open_new_unit(step): - step.given('I have opened a new course section in Studio') - step.given('I have added a new subsection') - step.given('I expand the first section') - old_url = world.browser.url - world.css_click('a.new-unit-item') - world.wait_for(lambda x: world.browser.url != old_url) +@world.absorb +def create_course_with_unit(): + """ + Prepare for tests by creating a course with a section, subsection, and unit. + Performs the following: + Clear out all courseware + Create a course with a section, subsection, and unit + Create a user and make that user a course author + Log the user into studio + Open the course from the dashboard + Expand the section and click on the New Unit link + The end result is the page where the user is editing the new unit + """ + world.clear_courses() + course = world.CourseFactory.create() + world.scenario_dict['COURSE'] = course + section = world.ItemFactory.create(parent_location=course.location) + world.ItemFactory.create( + parent_location=section.location, + category='sequential', + display_name='Subsection One', + ) + user = create_studio_user(is_staff=False) + add_course_author(user, course) + + log_into_studio() + world.css_click('a.course-link') + + css_selectors = [ + 'div.section-item a.expand-collapse-icon', 'a.new-unit-item' + ] + for selector in css_selectors: + world.css_click(selector) + + world.wait_for_mathjax() + world.wait_for_xmodule() + + assert world.is_css_present('ul.new-component-type') + + +@step('I have clicked the new unit button$') +@step(u'I am in Studio editing a new unit$') +def edit_new_unit(step): + create_course_with_unit() @step('the save notification button is disabled') @@ -267,9 +303,9 @@ def confirm_the_prompt(step): assert_false(world.css_find(btn_css).visible) -@step(u'I am shown a (.*)$') -def i_am_shown_a_notification(step, notification_type): - assert world.is_css_present('.wrapper-%s' % notification_type) +@step(u'I am shown a prompt$') +def i_am_shown_a_notification(step): + assert world.is_css_present('.wrapper-prompt') def type_in_codemirror(index, text): diff --git a/cms/djangoapps/contentstore/features/component.feature b/cms/djangoapps/contentstore/features/component.feature index 3877cccc55..7f31eb6d69 100644 --- a/cms/djangoapps/contentstore/features/component.feature +++ b/cms/djangoapps/contentstore/features/component.feature @@ -80,9 +80,3 @@ Feature: CMS.Component Adding And I add a "Blank Advanced Problem" "Advanced Problem" component And I delete all components Then I see no components - - Scenario: I see a notification on save - Given I am in Studio editing a new unit - And I add a "Discussion" "single step" component - And I edit and save a component - Then I am shown a notification diff --git a/cms/djangoapps/contentstore/features/component.py b/cms/djangoapps/contentstore/features/component.py index bec0c9431b..f8425a3600 100644 --- a/cms/djangoapps/contentstore/features/component.py +++ b/cms/djangoapps/contentstore/features/component.py @@ -2,43 +2,19 @@ #pylint: disable=W0621 from lettuce import world, step -from nose.tools import assert_true, assert_in, assert_equal # pylint: disable=E0611 -from common import create_studio_user, add_course_author, log_into_studio - - -@step(u'I am in Studio editing a new unit$') -def add_unit(step): - world.clear_courses() - course = world.CourseFactory.create() - section = world.ItemFactory.create(parent_location=course.location) - world.ItemFactory.create( - parent_location=section.location, - category='sequential', - display_name='Subsection One',) - user = create_studio_user(is_staff=False) - add_course_author(user, course) - log_into_studio() - 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: - world.css_click(selector) +from nose.tools import assert_true, assert_in # pylint: disable=E0611 @step(u'I add this type of single step component:$') def add_a_single_step_component(step): - world.wait_for_xmodule() for step_hash in step.hashes: component = step_hash['Component'] assert_in(component, ['Discussion', 'Video']) - css_selector = 'a[data-type="{}"]'.format(component.lower()) - world.css_click(css_selector) + + world.create_component_instance( + step=step, + category='{}'.format(component.lower()), + ) @step(u'I see this type of single step component:$') @@ -53,51 +29,24 @@ def see_a_single_step_component(step): @step(u'I add this type of( Advanced)? (HTML|Problem) component:$') def add_a_multi_step_component(step, is_advanced, category): - def click_advanced(): - css = 'ul.problem-type-tabs a[href="#tab2"]' - world.css_click(css) - my_css = 'ul.problem-type-tabs li.ui-state-active a[href="#tab2"]' - assert(world.css_find(my_css)) - - def find_matching_link(): - """ - Find the link with the specified text. There should be one and only one. - """ - # The tab shows links for the given category - links = world.css_find('div.new-component-{} a'.format(category)) - - # Find the link whose text matches what you're looking for - matched_links = [link for link in links if link.text == step_hash['Component']] - - # There should be one and only one - assert_equal(len(matched_links), 1) - return matched_links[0] - - def click_link(): - link.click() - - world.wait_for_xmodule() - category = category.lower() for step_hash in step.hashes: - css_selector = 'a[data-type="{}"]'.format(category) - world.css_click(css_selector) - world.wait_for_invisible(css_selector) - - if is_advanced: - # Sometimes this click does not work if you go too fast. - world.retry_on_exception(click_advanced, max_attempts=5, ignored_exceptions=AssertionError) - - # Retry this in case the list is empty because you tried too fast. - link = world.retry_on_exception(func=find_matching_link, ignored_exceptions=AssertionError) - - # Wait for the link to be clickable. If you go too fast it is not. - world.retry_on_exception(click_link) + world.create_component_instance( + step=step, + category='{}'.format(category.lower()), + component_type=step_hash['Component'], + is_advanced=bool(is_advanced), + ) @step(u'I see (HTML|Problem) components in this order:') def see_a_multi_step_component(step, category): - components = world.css_find('li.component section.xmodule_display') + + # Wait for all components to finish rendering + selector = 'li.component section.xmodule_display' + world.wait_for(lambda _: len(world.css_find(selector)) == len(step.hashes)) + for idx, step_hash in enumerate(step.hashes): + if category == 'HTML': html_matcher = { 'Text': @@ -107,9 +56,11 @@ def see_a_multi_step_component(step, category): 'E-text Written in LaTeX': '

Example: E-text page

', } - assert_in(html_matcher[step_hash['Component']], components[idx].html) + actual_html = world.css_html(selector, index=idx) + assert_in(html_matcher[step_hash['Component']], actual_html) else: - assert_in(step_hash['Component'].upper(), components[idx].text) + actual_text = world.css_text(selector, index=idx) + assert_in(step_hash['Component'].upper(), actual_text) @step(u'I add a "([^"]*)" "([^"]*)" component$') diff --git a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py index 9881290eba..d816e6e4bb 100644 --- a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py +++ b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py @@ -2,30 +2,35 @@ #pylint: disable=C0111 from lettuce import world -from nose.tools import assert_equal, assert_true # pylint: disable=E0611 +from nose.tools import assert_equal, assert_true, assert_in # pylint: disable=E0611 from terrain.steps import reload_the_page @world.absorb -def create_component_instance(step, component_button_css, category, - expected_css, boilerplate=None, - has_multiple_templates=True): +def create_component_instance(step, category, component_type=None, is_advanced=False): + """ + Create a new component in a Unit. - click_new_component_button(step, component_button_css) + Parameters + ---------- + category: component type (discussion, html, problem, video) + component_type: for components with multiple templates, the link text in the menu + is_advanced: for html and problem, is the desired component under the + advanced menu + """ + assert_in(category, ['problem', 'html', 'video', 'discussion']) + + component_button_css = '.large-{}-icon'.format(category.lower()) + world.css_click(component_button_css) if category in ('problem', 'html'): + world.wait_for_invisible(component_button_css) + click_component_from_menu(category, component_type, is_advanced) - def animation_done(_driver): - script = "$('div.new-component').css('display')" - return world.browser.evaluate_script(script) == 'none' - - world.wait_for(animation_done) - - if has_multiple_templates: - click_component_from_menu(category, boilerplate, expected_css) - - if category in ('video',): - world.wait_for_xmodule() + if category == 'problem': + expected_css = 'section.xmodule_CapaModule' + else: + expected_css = 'section.xmodule_{}Module'.format(category.title()) assert_true(world.is_css_present(expected_css)) @@ -33,29 +38,53 @@ def create_component_instance(step, component_button_css, category, @world.absorb def click_new_component_button(step, component_button_css): 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.absorb -def click_component_from_menu(category, boilerplate, expected_css): +def _click_advanced(): + css = 'ul.problem-type-tabs a[href="#tab2"]' + world.css_click(css) + my_css = 'ul.problem-type-tabs li.ui-state-active a[href="#tab2"]' + assert(world.css_find(my_css)) + + +def _find_matching_link(category, component_type): """ - Creates a component from `instance_id`. For components with more - than one template, clicks on `elem_css` to create the new - component. Components with only one template are created as soon - as the user clicks the appropriate button, so we assert that the - expected component is present. + Find the link with the specified text. There should be one and only one. """ - if boilerplate: - elem_css = "a[data-category='{}'][data-boilerplate='{}']".format(category, boilerplate) - else: - elem_css = "a[data-category='{}']:not([data-boilerplate])".format(category) - elements = world.css_find(elem_css) - assert_equal(len(elements), 1) - world.css_click(elem_css) + + # The tab shows links for the given category + links = world.css_find('div.new-component-{} a'.format(category)) + + # Find the link whose text matches what you're looking for + matched_links = [link for link in links if link.text == component_type] + + # There should be one and only one + assert_equal(len(matched_links), 1) + return matched_links[0] + + +def click_component_from_menu(category, component_type, is_advanced): + """ + Creates a component for a category with more + than one template, i.e. HTML and Problem. + For some problem types, it is necessary to click to + the Advanced tab. + The component_type is the link text, e.g. "Blank Common Problem" + """ + if is_advanced: + # Sometimes this click does not work if you go too fast. + world.retry_on_exception(_click_advanced, + ignored_exceptions=AssertionError) + + # Retry this in case the list is empty because you tried too fast. + link = world.retry_on_exception( + lambda: _find_matching_link(category, component_type), + ignored_exceptions=AssertionError + ) + + # Wait for the link to be clickable. If you go too fast it is not. + world.retry_on_exception(lambda: link.click()) @world.absorb diff --git a/cms/djangoapps/contentstore/features/course-overview.feature b/cms/djangoapps/contentstore/features/course-overview.feature index 80b400a58e..a10237de5d 100644 --- a/cms/djangoapps/contentstore/features/course-overview.feature +++ b/cms/djangoapps/contentstore/features/course-overview.feature @@ -58,20 +58,3 @@ Feature: CMS.Course Overview And I click the "Expand All Sections" link Then I see the "Collapse All Sections" link And all sections are expanded - - Scenario: Notification is shown on grading status changes - Given I have a course with 1 section - When I navigate to the course overview page - And I change an assignment's grading status - Then I am shown a notification - - # Notification is not shown on reorder for IE - # Safari does not have moveMouseTo implemented - @skip_internetexplorer - @skip_safari - Scenario: Notification is shown on subsection reorder - Given I have opened a new course section in Studio - And I have added a new subsection - And I have added a new subsection - When I reorder subsections - Then I am shown a notification diff --git a/cms/djangoapps/contentstore/features/course-overview.py b/cms/djangoapps/contentstore/features/course-overview.py index 57e9d13501..6890e39491 100644 --- a/cms/djangoapps/contentstore/features/course-overview.py +++ b/cms/djangoapps/contentstore/features/course-overview.py @@ -91,8 +91,7 @@ def i_expand_a_section(step): @step(u'I see the "([^"]*)" link$') def i_see_the_span_with_text(step, text): span_locator = '.toggle-button-sections span' - assert_true(world.is_css_present(span_locator)) - assert_equal(world.css_value(span_locator), text) + assert_true(world.css_has_value(span_locator, text)) assert_true(world.css_visible(span_locator)) @@ -128,10 +127,10 @@ def change_grading_status(step): @step(u'I reorder subsections') def reorder_subsections(_step): - draggable_css = 'a.drag-handle' + draggable_css = '.subsection-drag-handle' ele = world.css_find(draggable_css).first ele.action_chains.drag_and_drop_by_offset( ele._element, - 30, - 0 + 0, + 25 ).perform() diff --git a/cms/djangoapps/contentstore/features/course-settings.py b/cms/djangoapps/contentstore/features/course-settings.py index 7ec6a1071a..f92df428ef 100644 --- a/cms/djangoapps/contentstore/features/course-settings.py +++ b/cms/djangoapps/contentstore/features/course-settings.py @@ -151,9 +151,10 @@ def i_see_new_course_image(_step): assert len(images) == 1 img = images[0] expected_src = '/c4x/MITx/999/asset/image.jpg' + # Don't worry about the domain in the URL - assert img['src'].endswith(expected_src), "Was looking for {expected}, found {actual}".format( - expected=expected_src, actual=img['src']) + success_func = lambda _: img['src'].endswith(expected_src) + world.wait_for(success_func) @step('the image URL should be present in the field') diff --git a/cms/djangoapps/contentstore/features/course-team.py b/cms/djangoapps/contentstore/features/course-team.py index deba2d820d..4ccac98020 100644 --- a/cms/djangoapps/contentstore/features/course-team.py +++ b/cms/djangoapps/contentstore/features/course-team.py @@ -50,8 +50,8 @@ def other_delete_self(_step): @step(u'I make "([^"]*)" a course team admin') def make_course_team_admin(_step, name): - admin_btn_css = '.user-item[data-email="{email}"] .user-actions .add-admin-role'.format( - email=name+'@edx.org') + admin_btn_css = '.user-item[data-email="{name}@edx.org"] .user-actions .add-admin-role'.format( + name=name) world.css_click(admin_btn_css) @@ -80,8 +80,8 @@ def see_course(_step, do_not_see, gender='self'): @step(u'"([^"]*)" should( not)? be marked as an admin') def marked_as_admin(_step, name, not_marked_admin): - flag_css = '.user-item[data-email="{email}"] .flag-role.flag-role-admin'.format( - email=name+'@edx.org') + flag_css = '.user-item[data-email="{name}@edx.org"] .flag-role.flag-role-admin'.format( + name=name) if not_marked_admin: assert world.is_css_not_present(flag_css) else: diff --git a/cms/djangoapps/contentstore/features/course_import.py b/cms/djangoapps/contentstore/features/course_import.py new file mode 100644 index 0000000000..84b7affe30 --- /dev/null +++ b/cms/djangoapps/contentstore/features/course_import.py @@ -0,0 +1,21 @@ +import os +from lettuce import world +from django.conf import settings + + +def import_file(filename): + world.browser.execute_script("$('input.file-input').css('display', 'block')") + path = os.path.join(settings.COMMON_TEST_DATA_ROOT, "imports", filename) + world.browser.attach_file('course-data', os.path.abspath(path)) + world.css_click('input.submit-button') + # Go to course outline + world.click_course_content() + outline_css = 'li.nav-course-courseware-outline a' + world.css_click(outline_css) + + +def go_to_import(): + menu_css = 'li.nav-course-tools' + import_css = 'li.nav-course-tools-import a' + world.css_click(menu_css) + world.css_click(import_css) diff --git a/cms/djangoapps/contentstore/features/discussion-editor.feature b/cms/djangoapps/contentstore/features/discussion-editor.feature index 7278accf0b..17904e8820 100644 --- a/cms/djangoapps/contentstore/features/discussion-editor.feature +++ b/cms/djangoapps/contentstore/features/discussion-editor.feature @@ -2,7 +2,7 @@ Feature: CMS.Discussion Component Editor As a course author, I want to be able to create discussion components. - Scenario: User can view metadata + Scenario: User can view discussion component metadata Given I have created a Discussion Tag And I edit and select Settings Then I see three alphabetized settings and their expected values @@ -14,7 +14,3 @@ Feature: CMS.Discussion Component Editor And I edit and select Settings Then I can modify the display name And my display name change is persisted on save - - Scenario: Creating a discussion takes a single click - Given I have clicked the new unit button - Then creating a discussion takes a single click diff --git a/cms/djangoapps/contentstore/features/discussion-editor.py b/cms/djangoapps/contentstore/features/discussion-editor.py index d68860ff49..0bae84459f 100644 --- a/cms/djangoapps/contentstore/features/discussion-editor.py +++ b/cms/djangoapps/contentstore/features/discussion-editor.py @@ -6,11 +6,10 @@ from lettuce import world, step @step('I have created a Discussion Tag$') def i_created_discussion_tag(step): + world.create_course_with_unit() world.create_component_instance( - step, '.large-discussion-icon', - 'discussion', - '.xmodule_DiscussionModule', - has_multiple_templates=False + step=step, + category='discussion', ) @@ -22,12 +21,3 @@ def i_see_only_the_settings_and_values(step): ['Display Name', "Discussion", False], ['Subcategory', "Topic-Level Student-Visible Label", False] ]) - - -@step('creating a discussion takes a single click') -def discussion_takes_a_single_click(step): - component_css = '.xmodule_DiscussionModule' - assert world.is_css_not_present(component_css) - - world.css_click("a[data-category='discussion']") - assert world.is_css_present(component_css) diff --git a/cms/djangoapps/contentstore/features/grading.feature b/cms/djangoapps/contentstore/features/grading.feature index f3ce1823e6..6c357a171e 100644 --- a/cms/djangoapps/contentstore/features/grading.feature +++ b/cms/djangoapps/contentstore/features/grading.feature @@ -59,6 +59,17 @@ Feature: CMS.Course Grading And I go back to the main course page Then I do see the assignment name "New Type" + # Note that "7" is a special weight because it revealed rounding errors (STUD-826). + Scenario: Users can set weight to Assignment types + Given I have opened a new course in Studio + And I am viewing the grading settings + When I add a new assignment type "New Type" + And I set the assignment weight to "7" + And I press the "Save" notification button + Then the assignment weight is displayed as "7" + And I reload the page + Then the assignment weight is displayed as "7" + Scenario: Settings are only persisted when saved Given I have opened a new course in Studio And I have populated the course diff --git a/cms/djangoapps/contentstore/features/grading.py b/cms/djangoapps/contentstore/features/grading.py index 24beefcd6a..dcc11857d8 100644 --- a/cms/djangoapps/contentstore/features/grading.py +++ b/cms/djangoapps/contentstore/features/grading.py @@ -106,6 +106,22 @@ def add_assignment_type(step, new_name): new_assignment._element.send_keys(new_name) +@step(u'I set the assignment weight to "([^"]*)"$') +def set_weight(step, weight): + weight_id = '#course-grading-assignment-gradeweight' + weight_field = world.css_find(weight_id)[-1] + old_weight = world.css_value(weight_id, -1) + for count in range(len(old_weight)): + weight_field._element.send_keys(Keys.END, Keys.BACK_SPACE) + weight_field._element.send_keys(weight) + + +@step(u'the assignment weight is displayed as "([^"]*)"$') +def verify_weight(step, weight): + weight_id = '#course-grading-assignment-gradeweight' + assert_equal(world.css_value(weight_id, -1), weight) + + @step(u'I have populated the course') def populate_course(step): step.given('I have added a new section') @@ -164,7 +180,7 @@ def cannot_edit_fail(_step): def i_change_grace_period(_step, grace_period): grace_period_css = '#course-grading-graceperiod' ele = world.css_find(grace_period_css).first - + # Sometimes it takes a moment for the JavaScript # to populate the field. If we don't wait for # this to happen, then we can end up with diff --git a/cms/djangoapps/contentstore/features/html-editor.py b/cms/djangoapps/contentstore/features/html-editor.py index d89f052dcc..f00db67c07 100644 --- a/cms/djangoapps/contentstore/features/html-editor.py +++ b/cms/djangoapps/contentstore/features/html-editor.py @@ -6,9 +6,11 @@ from lettuce import world, step @step('I have created a Blank HTML Page$') def i_created_blank_html_page(step): + world.create_course_with_unit() world.create_component_instance( - step, '.large-html-icon', 'html', - '.xmodule_HtmlModule' + step=step, + category='html', + component_type='Text' ) @@ -18,11 +20,10 @@ def i_see_only_the_html_display_name(step): @step('I have created an E-text Written in LaTeX$') -def i_created_blank_html_page(step): +def i_created_etext_in_latex(step): + world.create_course_with_unit() world.create_component_instance( - step, - '.large-html-icon', - 'html', - '.xmodule_HtmlModule', - 'latex_html.yaml' + step=step, + category='html', + component_type='E-text Written in LaTeX' ) diff --git a/cms/djangoapps/contentstore/features/problem-editor.feature b/cms/djangoapps/contentstore/features/problem-editor.feature index e3f659a929..f3b75ebf7e 100644 --- a/cms/djangoapps/contentstore/features/problem-editor.feature +++ b/cms/djangoapps/contentstore/features/problem-editor.feature @@ -89,3 +89,13 @@ Feature: CMS.Problem Editor When I edit and compile the High Level Source Then my change to the High Level Source is persisted And when I view the High Level Source I see my changes + + Scenario: Exceptions don't cause problem to be uneditable (bug STUD-786) + Given I have an empty course + And I go to the import page + And I import the file "get_html_exception_test.tar.gz" + When I go to the unit "Probability and BMI" + And I click on "edit a draft" + Then I see a message that says "We're having trouble rendering your component" + And I can edit the problem + diff --git a/cms/djangoapps/contentstore/features/problem-editor.py b/cms/djangoapps/contentstore/features/problem-editor.py index fca2249066..ef8a3b5a4a 100644 --- a/cms/djangoapps/contentstore/features/problem-editor.py +++ b/cms/djangoapps/contentstore/features/problem-editor.py @@ -1,9 +1,12 @@ # disable missing docstring #pylint: disable=C0111 +import json from lettuce import world, step from nose.tools import assert_equal, assert_true # pylint: disable=E0611 -from common import type_in_codemirror +from common import type_in_codemirror, open_new_course +from course_import import import_file, go_to_import + DISPLAY_NAME = "Display Name" MAXIMUM_ATTEMPTS = "Maximum Attempts" @@ -14,17 +17,16 @@ SHOW_ANSWER = "Show Answer" @step('I have created a Blank Common Problem$') def i_created_blank_common_problem(step): + world.create_course_with_unit() world.create_component_instance( - step, - '.large-problem-icon', - 'problem', - '.xmodule_CapaModule', - 'blank_common.yaml' + step=step, + category='problem', + component_type='Blank Common Problem' ) @step('I edit and select Settings$') -def i_edit_and_select_settings(step): +def i_edit_and_select_settings(_step): world.edit_component_and_select_settings() @@ -41,7 +43,7 @@ def i_see_advanced_settings_with_values(step): @step('I can modify the display name') -def i_can_modify_the_display_name(step): +def i_can_modify_the_display_name(_step): # Verifying that the display name can be a string containing a floating point value # (to confirm that we don't throw an error because it is of the wrong type). index = world.get_setting_entry_index(DISPLAY_NAME) @@ -58,7 +60,7 @@ def my_display_name_change_is_persisted_on_save(step): @step('I can specify special characters in the display name') -def i_can_modify_the_display_name_with_special_chars(step): +def i_can_modify_the_display_name_with_special_chars(_step): index = world.get_setting_entry_index(DISPLAY_NAME) world.css_fill('.wrapper-comp-setting .setting-input', "updated ' \" &", index=index) if world.is_firefox(): @@ -73,7 +75,7 @@ def special_chars_persisted_on_save(step): @step('I can revert the display name to unset') -def can_revert_display_name_to_unset(step): +def can_revert_display_name_to_unset(_step): world.revert_setting_entry(DISPLAY_NAME) verify_unset_display_name() @@ -85,7 +87,7 @@ def my_display_name_is_persisted_on_save(step): @step('I can select Per Student for Randomization') -def i_can_select_per_student_for_randomization(step): +def i_can_select_per_student_for_randomization(_step): world.browser.select(RANDOMIZATION, "Per Student") verify_modified_randomization() @@ -104,7 +106,7 @@ def i_can_revert_to_default_for_randomization(step): @step('I can set the weight to "(.*)"?') -def i_can_set_weight(step, weight): +def i_can_set_weight(_step, weight): set_weight(weight) verify_modified_weight() @@ -164,25 +166,24 @@ def cancel_does_not_save_changes(step): @step('I have created a LaTeX Problem') def create_latex_problem(step): - world.click_new_component_button(step, '.large-problem-icon') - - def animation_done(_driver): - return world.browser.evaluate_script("$('div.new-component').css('display')") == 'none' - world.wait_for(animation_done) - # Go to advanced tab. - world.css_click('#ui-id-2') - world.click_component_from_menu("problem", "latex_problem.yaml", '.xmodule_CapaModule') + world.create_course_with_unit() + world.create_component_instance( + step=step, + category='problem', + component_type='Problem Written in LaTeX', + is_advanced=True + ) @step('I edit and compile the High Level Source') -def edit_latex_source(step): +def edit_latex_source(_step): open_high_level_source() type_in_codemirror(1, "hi") world.css_click('.hls-compile') @step('my change to the High Level Source is persisted') -def high_level_source_persisted(step): +def high_level_source_persisted(_step): def verify_text(driver): css_sel = '.problem div>span' return world.css_text(css_sel) == 'hi' @@ -191,11 +192,53 @@ def high_level_source_persisted(step): @step('I view the High Level Source I see my changes') -def high_level_source_in_editor(step): +def high_level_source_in_editor(_step): open_high_level_source() assert_equal('hi', world.css_value('.source-edit-box')) +@step(u'I have an empty course') +def i_have_empty_course(step): + open_new_course() + + +@step(u'I go to the import page') +def i_go_to_import(_step): + go_to_import() + + +@step(u'I import the file "([^"]*)"$') +def i_import_the_file(_step, filename): + import_file(filename) + + +@step(u'I click on "edit a draft"$') +def i_edit_a_draft(_step): + world.css_click("a.create-draft") + + +@step(u'I go to the vertical "([^"]*)"$') +def i_go_to_vertical(_step, vertical): + world.css_click("span:contains('{0}')".format(vertical)) + + +@step(u'I go to the unit "([^"]*)"$') +def i_go_to_unit(_step, unit): + loc = "window.location = $(\"span:contains('{0}')\").closest('a').attr('href')".format(unit) + world.browser.execute_script(loc) + + +@step(u'I see a message that says "([^"]*)"$') +def i_can_see_message(_step, msg): + msg = json.dumps(msg) # escape quotes + world.css_has_text("h2.title", msg) + + +@step(u'I can edit the problem$') +def i_can_edit_problem(_step): + world.edit_component() + + def verify_high_level_source_links(step, visible): if visible: assert_true(world.is_css_present('.launch-latex-compiler'), diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py index 3fea8637c6..b6f55969bb 100644 --- a/cms/djangoapps/contentstore/features/section.py +++ b/cms/djangoapps/contentstore/features/section.py @@ -5,8 +5,6 @@ from lettuce import world, step from common import * from nose.tools import assert_equal # pylint: disable=E0611 -############### ACTIONS #################### - @step('I click the New Section link$') def i_click_new_section_link(_step): @@ -53,9 +51,6 @@ def i_see_a_mini_notification(_step, _type): assert world.is_css_present(saving_css) -############ ASSERTIONS ################### - - @step('I see my section on the Courseware page$') def i_see_my_section_on_the_courseware_page(_step): see_my_section_on_the_courseware_page('My Section') @@ -125,8 +120,6 @@ def the_section_release_date_is_updated(_step): assert_equal(status_text, 'Will Release: 12/25/2013 at 00:00 UTC') -############ HELPER METHODS ################### - def save_section_name(name): name_css = '.new-section-name' save_css = '.new-section-name-save' diff --git a/cms/djangoapps/contentstore/features/textbooks.py b/cms/djangoapps/contentstore/features/textbooks.py index 2cf6683d6d..bac4e6bebc 100644 --- a/cms/djangoapps/contentstore/features/textbooks.py +++ b/cms/djangoapps/contentstore/features/textbooks.py @@ -47,7 +47,7 @@ def name_textbook(_step, name): @step(u'I name the (first|second|third) chapter "([^"]*)"') def name_chapter(_step, ordinal, name): index = ["first", "second", "third"].index(ordinal) - input_css = ".textbook .chapter{i} input.chapter-name".format(i=index+1) + input_css = ".textbook .chapter{i} input.chapter-name".format(i=index + 1) world.css_fill(input_css, name) if world.is_firefox(): world.trigger_event(input_css) @@ -56,7 +56,7 @@ def name_chapter(_step, ordinal, name): @step(u'I type in "([^"]*)" for the (first|second|third) chapter asset') def asset_chapter(_step, name, ordinal): index = ["first", "second", "third"].index(ordinal) - input_css = ".textbook .chapter{i} input.chapter-asset-path".format(i=index+1) + input_css = ".textbook .chapter{i} input.chapter-asset-path".format(i=index + 1) world.css_fill(input_css, name) if world.is_firefox(): world.trigger_event(input_css) @@ -65,7 +65,7 @@ def asset_chapter(_step, name, ordinal): @step(u'I click the Upload Asset link for the (first|second|third) chapter') def click_upload_asset(_step, ordinal): index = ["first", "second", "third"].index(ordinal) - button_css = ".textbook .chapter{i} .action-upload".format(i=index+1) + button_css = ".textbook .chapter{i} .action-upload".format(i=index + 1) world.css_click(button_css) diff --git a/cms/djangoapps/contentstore/features/upload.py b/cms/djangoapps/contentstore/features/upload.py index cf0d92fc94..22a4425686 100644 --- a/cms/djangoapps/contentstore/features/upload.py +++ b/cms/djangoapps/contentstore/features/upload.py @@ -191,7 +191,7 @@ def view_asset(_step, status): # Note that world.visit would trigger a 403 error instead of displaying "Unauthorized" # Instead, we can drop back into the selenium driver get command. world.browser.driver.get(url) - assert_equal(world.css_text('body'),expected_text) + assert_equal(world.css_text('body'), expected_text) @step('I see a confirmation that the file was deleted$') diff --git a/cms/djangoapps/contentstore/features/video-editor.feature b/cms/djangoapps/contentstore/features/video-editor.feature index d5b4a2a03b..c281ca453e 100644 --- a/cms/djangoapps/contentstore/features/video-editor.feature +++ b/cms/djangoapps/contentstore/features/video-editor.feature @@ -15,12 +15,17 @@ Feature: CMS.Video Component Editor Then I can modify the display name And my video display name change is persisted on save + # Disabling this 10/7/13 due to nondeterministic behavior + # in master. The failure seems to occur when YouTube does + # not respond quickly enough, so that the video player + # doesn't load. + # # Sauce Labs cannot delete cookies - @skip_sauce - Scenario: Captions are hidden when "show captions" is false - Given I have created a Video component with subtitles - And I have set "show captions" to False - Then when I view the video it does not show the captions + # @skip_sauce + #Scenario: Captions are hidden when "show captions" is false + # Given I have created a Video component with subtitles + # And I have set "show captions" to False + # Then when I view the video it does not show the captions # Sauce Labs cannot delete cookies @skip_sauce diff --git a/cms/djangoapps/contentstore/features/video.feature b/cms/djangoapps/contentstore/features/video.feature index 105a26c868..a47b2313c6 100644 --- a/cms/djangoapps/contentstore/features/video.feature +++ b/cms/djangoapps/contentstore/features/video.feature @@ -2,28 +2,33 @@ Feature: CMS.Video Component As a course author, I want to be able to view my created videos in Studio. + # 1 # Video Alpha Features will work in Firefox only when Firefox is the active window Scenario: Autoplay is disabled in Studio Given I have created a Video component Then when I view the video it does not have autoplay enabled + # 2 Scenario: Creating a video takes a single click Given I have clicked the new unit button Then creating a video takes a single click + # 3 # Sauce Labs cannot delete cookies - @skip_sauce - Scenario: Captions are hidden correctly - Given I have created a Video component with subtitles - And I have hidden captions - Then when I view the video it does not show the captions + # @skip_sauce + #Scenario: Captions are hidden correctly + # Given I have created a Video component with subtitles + # And I have hidden captions + # Then when I view the video it does not show the captions + # 4 # Sauce Labs cannot delete cookies @skip_sauce Scenario: Captions are shown correctly Given I have created a Video component with subtitles Then when I view the video it does show the captions + # 5 # Sauce Labs cannot delete cookies @skip_sauce Scenario: Captions are toggled correctly @@ -31,7 +36,36 @@ Feature: CMS.Video Component And I have toggled captions Then when I view the video it does show the captions + # 6 Scenario: Video data is shown correctly Given I have created a video with only XML data And I reload the page Then the correct Youtube video is shown + + # 7 + # Scenario: Closed captions become visible when the mouse hovers over CC button + # Given I have created a Video component with subtitles + # And Make sure captions are closed + # Then Captions become "invisible" after 3 seconds + # And I hover over button "CC" + # Then Captions become "visible" + # And I hover over button "volume" + # Then Captions become "invisible" after 3 seconds + + # 8 + #Scenario: Open captions never become invisible + # Given I have created a Video component with subtitles + # And Make sure captions are open + # Then Captions are "visible" + # And I hover over button "CC" + # Then Captions are "visible" + # And I hover over button "volume" + # Then Captions are "visible" + + # 9 + #Scenario: Closed captions are invisible when mouse doesn't hover on CC button + # Given I have created a Video component with subtitles + # And Make sure captions are closed + # Then Captions become "invisible" after 3 seconds + # And I hover over button "volume" + # Then Captions are "invisible" diff --git a/cms/djangoapps/contentstore/features/video.py b/cms/djangoapps/contentstore/features/video.py index 20db375184..80963ab556 100644 --- a/cms/djangoapps/contentstore/features/video.py +++ b/cms/djangoapps/contentstore/features/video.py @@ -4,14 +4,18 @@ from lettuce import world, step from xmodule.modulestore import Location from contentstore.utils import get_modulestore +BUTTONS = { + 'CC': '.hide-subtitles', + 'volume': '.volume', +} + @step('I have created a Video component$') def i_created_a_video_component(step): + world.create_course_with_unit() world.create_component_instance( - step, '.large-video-icon', - 'video', - '.xmodule_VideoModule', - has_multiple_templates=False + step=step, + category='video', ) @@ -19,6 +23,7 @@ def i_created_a_video_component(step): def i_created_a_video_with_subs(_step): _step.given('I have created a Video component with subtitles "OEoXaMPEzfM"') + @step('I have created a Video component with subtitles "([^"]*)"$') def i_created_a_video_with_subs_with_name(_step, sub_id): _step.given('I have created a Video component') @@ -115,3 +120,37 @@ def the_youtube_video_is_shown(_step): world.wait_for_xmodule() ele = world.css_find('.video').first assert ele['data-streams'].split(':')[1] == world.scenario_dict['YOUTUBE_ID'] + + +@step('Make sure captions are (.+)$') +def set_captions_visibility_state(_step, captions_state): + if captions_state == 'closed': + if world.css_visible('.subtitles'): + world.browser.find_by_css('.hide-subtitles').click() + else: + if not world.css_visible('.subtitles'): + world.browser.find_by_css('.hide-subtitles').click() + + +@step('I hover over button "([^"]*)"$') +def hover_over_button(_step, button): + world.css_find(BUTTONS[button.strip()]).mouse_over() + + +@step('Captions (?:are|become) "([^"]*)"$') +def are_captions_visibile(_step, visibility_state): + _step.given('Captions become "{0}" after 0 seconds'.format(visibility_state)) + + +@step('Captions (?:are|become) "([^"]*)" after (.+) seconds$') +def check_captions_visibility_state(_step, visibility_state, timeout): + timeout = int(timeout.strip()) + + # Captions become invisible by fading out. We must wait by a specified + # time. + world.wait(timeout) + + if visibility_state == 'visible': + assert world.css_visible('.subtitles') + else: + assert not world.css_visible('.subtitles') diff --git a/cms/djangoapps/contentstore/management/commands/dump_course_structure.py b/cms/djangoapps/contentstore/management/commands/dump_course_structure.py deleted file mode 100644 index 139c603172..0000000000 --- a/cms/djangoapps/contentstore/management/commands/dump_course_structure.py +++ /dev/null @@ -1,62 +0,0 @@ -""" -Script for dumping course dumping the course structure -""" -from django.core.management.base import BaseCommand, CommandError -from xmodule.course_module import CourseDescriptor -from xmodule.modulestore.django import modulestore -from json import dumps -from xmodule.modulestore.inheritance import own_metadata -from django.conf import settings - -filter_list = ['xml_attributes', 'checklists'] - - -class Command(BaseCommand): - """ - The Django command for dumping course structure - """ - help = '''Write out to stdout a structural and metadata information about a course in a flat dictionary serialized - in a JSON format. This can be used for analytics.''' - - def handle(self, *args, **options): - "Execute the command" - if len(args) < 2 or len(args) > 3: - raise CommandError("dump_course_structure requires two or more arguments: ||") - - course_id = args[0] - outfile = args[1] - - # use a user-specified database name, if present - # this is useful for doing dumps from databases restored from prod backups - if len(args) == 3: - settings.MODULESTORE['direct']['OPTIONS']['db'] = args[2] - - loc = CourseDescriptor.id_to_location(course_id) - - store = modulestore() - - course = None - try: - course = store.get_item(loc, depth=4) - except: - print('Could not find course at {0}'.format(course_id)) - return - - info = {} - - def dump_into_dict(module, info): - filtered_metadata = dict((key, value) for key, value in own_metadata(module).iteritems() - if key not in filter_list) - info[module.location.url()] = { - 'category': module.location.category, - 'children': module.children if hasattr(module, 'children') else [], - 'metadata': filtered_metadata - } - - for child in module.get_children(): - dump_into_dict(child, info) - - dump_into_dict(course, info) - - with open(outfile, 'w') as f: - f.write(dumps(info)) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 288e6443f7..7f09e4dd60 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -792,7 +792,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): source_location.tag, source_location.org, source_location.course, 'html', 'nonportable']) html_module = module_store.get_instance(source_location.course_id, html_module_location) - self.assertTrue(isinstance(html_module.data, basestring)) + self.assertIsInstance(html_module.data, basestring) new_data = html_module.data.replace('/static/', '/c4x/{0}/{1}/asset/'.format( source_location.org, source_location.course)) module_store.update_item(html_module_location, new_data) @@ -1273,6 +1273,47 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # export out to a tempdir export_to_xml(module_store, content_store, location, root_dir, 'test_export') + def test_export_course_without_content_store(self): + module_store = modulestore('direct') + content_store = contentstore() + + # Create toy course + + import_from_xml(module_store, 'common/test/data/', ['toy']) + location = CourseDescriptor.id_to_location('edX/toy/2012_Fall') + + # Add a sequence + + stub_location = Location(['i4x', 'edX', 'toy', 'sequential', 'vertical_sequential']) + sequential = module_store.get_item(stub_location) + module_store.update_children(sequential.location, sequential.children) + + # Get course and export it without a content_store + + course = module_store.get_item(location) + course.save() + + root_dir = path(mkdtemp_clean()) + + print 'Exporting to tempdir = {0}'.format(root_dir) + export_to_xml(module_store, None, location, root_dir, 'test_export_no_content_store') + + # Delete the course from module store and reimport it + + delete_course(module_store, content_store, location, commit=True) + + import_from_xml( + module_store, root_dir, ['test_export_no_content_store'], + draft_store=None, + static_content_store=None, + target_location_namespace=course.location + ) + + # Verify reimported course + + items = module_store.get_items(stub_location) + self.assertEqual(len(items), 1) + @override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE, MODULESTORE=TEST_MODULESTORE) class ContentStoreTest(ModuleStoreTestCase): @@ -1484,7 +1525,7 @@ class ContentStoreTest(ModuleStoreTestCase): resp = self.client.get(reverse('course_index', kwargs=data)) self.assertContains( resp, - '
', + '
', status_code=200, html=True ) @@ -1588,14 +1629,7 @@ class ContentStoreTest(ModuleStoreTestCase): 'name': loc.name})) self.assertEqual(resp.status_code, 200) - # static_pages - resp = self.client.get(reverse('static_pages', - kwargs={'org': loc.org, - 'course': loc.course, - 'coursename': loc.name})) - self.assertEqual(resp.status_code, 200) - - # static_pages + # asset_index resp = self.client.get(reverse('asset_index', kwargs={'org': loc.org, 'course': loc.course, diff --git a/cms/djangoapps/contentstore/tests/test_request_event.py b/cms/djangoapps/contentstore/tests/test_request_event.py index 6a513d689d..0126de66c6 100644 --- a/cms/djangoapps/contentstore/tests/test_request_event.py +++ b/cms/djangoapps/contentstore/tests/test_request_event.py @@ -3,7 +3,7 @@ import mock from django.test import TestCase from django.core.urlresolvers import reverse -from contentstore.views.requests import event as cms_user_track +from contentstore.views.helpers import event as cms_user_track class CMSLogTest(TestCase): diff --git a/cms/djangoapps/contentstore/views/__init__.py b/cms/djangoapps/contentstore/views/__init__.py index 10f6fb79a7..57e04f09d8 100644 --- a/cms/djangoapps/contentstore/views/__init__.py +++ b/cms/djangoapps/contentstore/views/__init__.py @@ -9,13 +9,13 @@ from .checklist import * from .component import * from .course import * from .error import * +from .helpers import * from .item import * from .import_export import * from .preview import * from .public import * from .user import * from .tabs import * -from .requests import * try: from .dev import * except ImportError: diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index deef87a403..6fd9d75628 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -26,7 +26,7 @@ from contentstore.utils import (get_modulestore, get_lms_link_for_item, from models.settings.course_grading import CourseGradingModel -from .requests import _xmodule_recurse +from .helpers import _xmodule_recurse from .access import has_access from xmodule.x_module import XModuleDescriptor from xblock.plugin import PluginMissingError diff --git a/cms/djangoapps/contentstore/views/requests.py b/cms/djangoapps/contentstore/views/helpers.py similarity index 100% rename from cms/djangoapps/contentstore/views/requests.py rename to cms/djangoapps/contentstore/views/helpers.py diff --git a/cms/djangoapps/contentstore/views/import_export.py b/cms/djangoapps/contentstore/views/import_export.py index 67e64d67d0..6252da0d0e 100644 --- a/cms/djangoapps/contentstore/views/import_export.py +++ b/cms/djangoapps/contentstore/views/import_export.py @@ -193,6 +193,7 @@ def import_course(request, org, course, name): if not dirpath: return JsonResponse( { + 'ErrMsg': _('Could not find the course.xml file in the package.'), 'Stage': 2 }, diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index c9edcd60a0..6313d520b8 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -12,7 +12,7 @@ from xmodule.modulestore.inheritance import own_metadata from util.json_request import expect_json, JsonResponse from ..utils import get_modulestore from .access import has_access -from .requests import _xmodule_recurse +from .helpers import _xmodule_recurse from xmodule.x_module import XModuleDescriptor __all__ = ['save_item', 'create_item', 'delete_item'] diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index b5cfc74a57..12df3fcf7a 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -6,7 +6,7 @@ from django.conf import settings from django.http import HttpResponse, Http404, HttpResponseBadRequest, HttpResponseForbidden from django.core.urlresolvers import reverse from django.contrib.auth.decorators import login_required -from mitxmako.shortcuts import render_to_response +from mitxmako.shortcuts import render_to_response, render_to_string from xmodule_modifiers import replace_static_urls, wrap_xmodule from xmodule.error_module import ErrorDescriptor @@ -22,7 +22,7 @@ from util.sandboxing import can_execute_unsafe_code import static_replace from .session_kv_store import SessionKeyValueStore -from .requests import render_from_lms +from .helpers import render_from_lms from .access import has_access from ..utils import get_course_for_item @@ -79,9 +79,17 @@ def preview_component(request, location): # can bind to it correctly component.runtime.wrappers.append(partial(wrap_xmodule, 'xmodule_edit.html')) + try: + content = component.render('studio_view').content + # catch exceptions indiscriminately, since after this point they escape the + # dungeon and surface as uneditable, unsaveable, and undeletable + # component-goblins. + except Exception as exc: # pylint: disable=W0703 + content = render_to_string('html_error.html', {'message': str(exc)}) + return render_to_response('component.html', { 'preview': get_preview_html(request, component, 0), - 'editor': component.runtime.render(component, None, 'studio_view').content, + 'editor': content }) @@ -95,11 +103,6 @@ def preview_module_system(request, preview_id, descriptor): descriptor: An XModuleDescriptor """ - def preview_field_data(descriptor): - "Helper method to create a DbModel from a descriptor" - student_data = DbModel(SessionKeyValueStore(request)) - return lms_field_data(descriptor._field_data, student_data) - course_id = get_course_for_item(descriptor.location).location.course_id if descriptor.location.category == 'static_tab': @@ -118,7 +121,6 @@ def preview_module_system(request, preview_id, descriptor): debug=True, replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_id=course_id), user=request.user, - xmodule_field_data=preview_field_data, can_execute_unsafe_code=(lambda: can_execute_unsafe_code(course_id)), mixins=settings.XBLOCK_MIXINS, course_id=course_id, @@ -136,7 +138,8 @@ def preview_module_system(request, preview_id, descriptor): getattr(descriptor, 'data_dir', descriptor.location.course), course_id=descriptor.location.org + '/' + descriptor.location.course + '/BOGUS_RUN_REPLACE_WHEN_AVAILABLE', ), - ) + ), + error_descriptor_class=ErrorDescriptor, ) @@ -148,17 +151,12 @@ def load_preview_module(request, preview_id, descriptor): preview_id (str): An identifier specifying which preview this module is used for descriptor: An XModuleDescriptor """ - system = preview_module_system(request, preview_id, descriptor) - try: - module = descriptor.xmodule(system) - except: - log.debug("Unable to load preview module", exc_info=True) - module = ErrorDescriptor.from_descriptor( - descriptor, - error_msg=exc_info_to_str(sys.exc_info()) - ).xmodule(system) - - return module + student_data = DbModel(SessionKeyValueStore(request)) + descriptor.bind_for_student( + preview_module_system(request, preview_id, descriptor), + lms_field_data(descriptor._field_data, student_data), # pylint: disable=protected-access + ) + return descriptor def get_preview_html(request, descriptor, idx): @@ -167,4 +165,8 @@ def get_preview_html(request, descriptor, idx): specified by the descriptor and idx. """ module = load_preview_module(request, str(idx), descriptor) - return module.runtime.render(module, None, "student_view").content + try: + content = module.render("student_view").content + except Exception as exc: # pylint: disable=W0703 + content = render_to_string('html_error.html', {'message': str(exc)}) + return content diff --git a/cms/djangoapps/contentstore/views/tabs.py b/cms/djangoapps/contentstore/views/tabs.py index f897fa1378..d19f4ae6a3 100644 --- a/cms/djangoapps/contentstore/views/tabs.py +++ b/cms/djangoapps/contentstore/views/tabs.py @@ -14,10 +14,9 @@ from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.django import modulestore from ..utils import get_course_for_item, get_modulestore -from .access import get_location_and_verify_access -__all__ = ['edit_tabs', 'reorder_static_tabs', 'static_pages'] +__all__ = ['edit_tabs', 'reorder_static_tabs'] def initialize_course_tabs(course): @@ -126,20 +125,6 @@ def edit_tabs(request, org, course, coursename): }) -@login_required -@ensure_csrf_cookie -def static_pages(request, org, course, coursename): - "Static pages view" - - location = get_location_and_verify_access(request, org, course, coursename) - - course = modulestore().get_item(location) - - return render_to_response('static-pages.html', { - 'context_course': course, - }) - - # "primitive" tab edit functions driven by the command line. # These should be replaced/deleted by a more capable GUI someday. # Note that the command line UI identifies the tabs with 1-based diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 1ad5374744..9f6d5031ea 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -12,20 +12,24 @@ from .common import * from logsettings import get_logger_config import os -# specified as an environment variable. Typically this is set -# in the service's upstart script and corresponds exactly to the service name. -# Service variants apply config differences via env and auth JSON files, -# the names of which correspond to the variant. + +# SERVICE_VARIANT specifies name of the variant used, which decides what JSON +# configuration files are read during startup. SERVICE_VARIANT = os.environ.get('SERVICE_VARIANT', None) -# when not variant is specified we attempt to load an unvaried -# config set. -CONFIG_PREFIX = "" +# CONFIG_ROOT specifies the directory where the JSON configuration +# files are expected to be found. If not specified, use the project +# directory. +CONFIG_ROOT = os.environ.get('CONFIG_ROOT', ENV_ROOT) + +# CONFIG_PREFIX specifies the prefix of the JSON configuration files, +# based on the service variant. If no variant is use, don't use a +# prefix. +CONFIG_PREFIX = SERVICE_VARIANT + "." if SERVICE_VARIANT else "" -if SERVICE_VARIANT: - CONFIG_PREFIX = SERVICE_VARIANT + "." ############### ALWAYS THE SAME ################################ + DEBUG = False TEMPLATE_DEBUG = False @@ -77,7 +81,7 @@ CELERY_QUEUES = { ############# NON-SECURE ENV CONFIG ############################## # Things like server locations, ports, etc. -with open(ENV_ROOT / CONFIG_PREFIX + "env.json") as env_file: +with open(CONFIG_ROOT / CONFIG_PREFIX + "env.json") as env_file: ENV_TOKENS = json.load(env_file) EMAIL_BACKEND = ENV_TOKENS.get('EMAIL_BACKEND', EMAIL_BACKEND) @@ -134,7 +138,7 @@ if "TRACKING_IGNORE_URL_PATTERNS" in ENV_TOKENS: ################ SECURE AUTH ITEMS ############################### # Secret things: passwords, access keys, etc. -with open(ENV_ROOT / CONFIG_PREFIX + "auth.json") as auth_file: +with open(CONFIG_ROOT / CONFIG_PREFIX + "auth.json") as auth_file: AUTH_TOKENS = json.load(auth_file) # If Segment.io key specified, load it and turn on Segment.io if the feature flag is set diff --git a/cms/envs/common.py b/cms/envs/common.py index dbf3647839..05a1b63f5f 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -218,6 +218,11 @@ STATICFILES_DIRS = [ TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html +# We want i18n to be turned off in production, at least until we have full localizations. +# Thus we want the Django translation engine to be disabled. Otherwise even without +# localization files, if the user's browser is set to a language other than us-en, +# strings like "login" and "password" will be translated and the rest of the page will be +# in English, which is confusing. USE_I18N = False USE_L10N = True diff --git a/cms/envs/dev.py b/cms/envs/dev.py index 6e4ce460c3..cb51ef37b1 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -9,6 +9,7 @@ from .common import * from logsettings import get_logger_config DEBUG = True +USE_I18N = True TEMPLATE_DEBUG = DEBUG LOGGING = get_logger_config(ENV_ROOT / "log", logging_env="dev", diff --git a/cms/envs/dev_shared_preview.py b/cms/envs/dev_shared_preview.py new file mode 100644 index 0000000000..119558ba05 --- /dev/null +++ b/cms/envs/dev_shared_preview.py @@ -0,0 +1,12 @@ +""" +This configuration is have localdev use a preview.localhost hostname for the preview LMS so that we can share +the same process between preview and published +""" + +# We intentionally define lots of variables that aren't used, and +# want to import all variables from base settings files +# pylint: disable=W0401, W0614 + +from .dev import * + +MITX_FEATURES['PREVIEW_LMS_BASE'] = "preview.localhost:8000" diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee index 39214fd6a7..55381d82f8 100644 --- a/cms/static/coffee/spec/main.coffee +++ b/cms/static/coffee/spec/main.coffee @@ -1,5 +1,5 @@ requirejs.config({ - paths: { + 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", @@ -22,7 +22,7 @@ requirejs.config({ "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", + "youtube": "//www.youtube.com/player_api?noext", "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", @@ -32,9 +32,11 @@ requirejs.config({ "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", + "draggabilly": "xmodule_js/common_static/js/vendor/draggabilly.pkgd", + "domReady": "xmodule_js/common_static/js/vendor/domReady", "coffee/src/ajax_prefix": "xmodule_js/common_static/coffee/src/ajax_prefix" - }, + } shim: { "gettext": { exports: "gettext" @@ -100,6 +102,9 @@ requirejs.config({ deps: ["backbone"], exports: "Backbone.Associations" }, + "youtube": { + exports: "YT" + }, "codemirror": { exports: "CodeMirror" }, @@ -139,12 +144,14 @@ define([ "coffee/spec/models/course_spec", "coffee/spec/models/metadata_spec", "coffee/spec/models/module_spec", "coffee/spec/models/section_spec", + "coffee/spec/models/settings_course_grader_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/overview_spec", "coffee/spec/views/textbook_spec", "coffee/spec/views/upload_spec", # these tests are run separate in the cms-squire suite, due to process diff --git a/cms/static/coffee/spec/main_squire.coffee b/cms/static/coffee/spec/main_squire.coffee index 61e00ab03f..28a3f03ce1 100644 --- a/cms/static/coffee/spec/main_squire.coffee +++ b/cms/static/coffee/spec/main_squire.coffee @@ -22,7 +22,7 @@ requirejs.config({ "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", + "youtube": "//www.youtube.com/player_api?noext", "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", @@ -100,6 +100,9 @@ requirejs.config({ deps: ["backbone"], exports: "Backbone.Associations" }, + "youtube": { + exports: "YT" + }, "codemirror": { exports: "CodeMirror" }, diff --git a/cms/static/coffee/spec/models/settings_course_grader_spec.coffee b/cms/static/coffee/spec/models/settings_course_grader_spec.coffee new file mode 100644 index 0000000000..f6252a3590 --- /dev/null +++ b/cms/static/coffee/spec/models/settings_course_grader_spec.coffee @@ -0,0 +1,20 @@ +define ["js/models/settings/course_grader"], (CourseGrader) -> + describe "CourseGraderModel", -> + describe "parseWeight", -> + it "converts a float to an integer", -> + model = new CourseGrader({weight: 7.0001, min_count: 3.67, drop_count: 1.88}, {parse:true}) + expect(model.get('weight')).toBe(7) + expect(model.get('min_count')).toBe(3) + expect(model.get('drop_count')).toBe(1) + + it "converts a string to an integer", -> + model = new CourseGrader({weight: '7.0001', min_count: '3.67', drop_count: '1.88'}, {parse:true}) + expect(model.get('weight')).toBe(7) + expect(model.get('min_count')).toBe(3) + expect(model.get('drop_count')).toBe(1) + + it "does a no-op for integers", -> + model = new CourseGrader({weight: 7, min_count: 3, drop_count: 1}, {parse:true}) + expect(model.get('weight')).toBe(7) + expect(model.get('min_count')).toBe(3) + expect(model.get('drop_count')).toBe(1) diff --git a/cms/static/coffee/spec/setup_require.coffee b/cms/static/coffee/spec/setup_require.coffee deleted file mode 100644 index 0a66f9741f..0000000000 --- a/cms/static/coffee/spec/setup_require.coffee +++ /dev/null @@ -1,61 +0,0 @@ -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"] diff --git a/cms/static/coffee/spec/views/course_info_spec.coffee b/cms/static/coffee/spec/views/course_info_spec.coffee index b06868faa3..de33b85083 100644 --- a/cms/static/coffee/spec/views/course_info_spec.coffee +++ b/cms/static/coffee/spec/views/course_info_spec.coffee @@ -10,6 +10,7 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model + """ beforeEach -> @@ -45,13 +46,56 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model preventDefault : () -> 'no op' } - @createNewUpdate = () -> + @createNewUpdate = (text) -> # Edit button is not in the template under test (it is in parent HTML). # Therefore call onNew directly. @courseInfoEdit.onNew(@event) - spyOn(@courseInfoEdit.$codeMirror, 'getValue').andReturn('/static/image.jpg') + spyOn(@courseInfoEdit.$codeMirror, 'getValue').andReturn(text) @courseInfoEdit.$el.find('.save-button').click() + @cancelNewCourseInfo = (useCancelButton) -> + spyOn(@courseInfoEdit.$modalCover, 'show').andCallThrough() + spyOn(@courseInfoEdit.$modalCover, 'hide').andCallThrough() + + @courseInfoEdit.onNew(@event) + expect(@courseInfoEdit.$modalCover.show).toHaveBeenCalled() + + spyOn(@courseInfoEdit.$codeMirror, 'getValue').andReturn('unsaved changes') + model = @collection.at(0) + spyOn(model, "save").andCallThrough() + + cancelEditingUpdate(useCancelButton) + + expect(@courseInfoEdit.$modalCover.hide).toHaveBeenCalled() + expect(model.save).not.toHaveBeenCalled() + previewContents = @courseInfoEdit.$el.find('.update-contents').html() + expect(previewContents).not.toEqual('unsaved changes') + + @cancelExistingCourseInfo = (useCancelButton) -> + @createNewUpdate('existing update') + + spyOn(@courseInfoEdit.$modalCover, 'show').andCallThrough() + spyOn(@courseInfoEdit.$modalCover, 'hide').andCallThrough() + @courseInfoEdit.$el.find('.edit-button').click() + expect(@courseInfoEdit.$modalCover.show).toHaveBeenCalled() + + spyOn(@courseInfoEdit.$codeMirror, 'getValue').andReturn('modification') + model = @collection.at(0) + spyOn(model, "save").andCallThrough() + + cancelEditingUpdate(useCancelButton) + + expect(@courseInfoEdit.$modalCover.hide).toHaveBeenCalled() + expect(model.save).not.toHaveBeenCalled() + previewContents = @courseInfoEdit.$el.find('.update-contents').html() + expect(previewContents).toEqual('existing update') + + cancelEditingUpdate = (update, useCancelButton) -> + if useCancelButton + update.$el.find('.cancel-button').click() + else + $('.modal-cover').click() + afterEach -> @xhrRestore() @@ -75,19 +119,30 @@ define ["js/views/course_info_handout", "js/views/course_info_update", "js/model it "does rewrite links for preview", -> # Create a new update. - @createNewUpdate() + @createNewUpdate('/static/image.jpg') # Verify the link is rewritten for preview purposes. previewContents = @courseInfoEdit.$el.find('.update-contents').html() expect(previewContents).toEqual('base-asset-url/image.jpg') it "shows static links in edit mode", -> - @createNewUpdate() + @createNewUpdate('/static/image.jpg') # Click edit and verify CodeMirror contents. @courseInfoEdit.$el.find('.edit-button').click() expect(@courseInfoEdit.$codeMirror.getValue()).toEqual('/static/image.jpg') + it "removes newly created course info on cancel", -> + @cancelNewCourseInfo(true) + + it "removes newly created course info on click outside modal", -> + @cancelNewCourseInfo(false) + + it "does not remove existing course info on cancel", -> + @cancelExistingCourseInfo(true) + + it "does not remove existing course info on click outside modal", -> + @cancelExistingCourseInfo(false) describe "Course Handouts", -> handoutsTemplate = readFixtures('course_info_handouts.underscore') diff --git a/cms/static/coffee/spec/views/module_edit_spec.coffee b/cms/static/coffee/spec/views/module_edit_spec.coffee index 99b4dd65d2..157fb18e6a 100644 --- a/cms/static/coffee/spec/views/module_edit_spec.coffee +++ b/cms/static/coffee/spec/views/module_edit_spec.coffee @@ -5,7 +5,6 @@ define ["coffee/src/views/module_edit", "xmodule"], (ModuleEdit) -> @stubModule = jasmine.createSpy("Module") @stubModule.id = 'stub-id' - setFixtures """
  • @@ -19,7 +18,7 @@ define ["coffee/src/views/module_edit", "xmodule"], (ModuleEdit) -> Edit Delete
    - +
    diff --git a/cms/static/coffee/spec/views/overview_spec.coffee b/cms/static/coffee/spec/views/overview_spec.coffee new file mode 100644 index 0000000000..c5cee89866 --- /dev/null +++ b/cms/static/coffee/spec/views/overview_spec.coffee @@ -0,0 +1,377 @@ +define ["js/views/overview", "js/views/feedback_notification", "sinon", "js/base", "date", "jquery.timepicker"], +(OverviewDragger, Notification, sinon) -> + + describe "Course Overview", -> + beforeEach -> + appendSetFixtures """ + + """ + + appendSetFixtures """ +
    +
    +

    Section Release Date

    +
    +
    + + +
    +
    + + +
    +
    +

    On the date set above, this section – – will be released to students. Any units marked private will only be visible to admins.

    +
    +
    + SaveCancel +
    +
    + """ + + appendSetFixtures """ +
    + +
    + """ + + appendSetFixtures """ +
      +
    1. +
        +
      1. +
      2. +
      3. +
      +
    2. +
    3. +
        +
      1. +
      +
    4. +
    5. +
        + +
      + """ + + 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(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) + + OverviewDragger.makeDraggable( + '.unit', + '.unit-drag-handle', + 'ol.sortable-unit-list', + 'li.branch, article.subsection-body' + ) + + 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() + + # Fails sporadically in Jenkins. +# it "should delete model when delete is clicked", -> +# $('a.delete-section-button').click() +# $('a.action-primary').click() +# expect(@requests[0].url).toEqual('/delete_item') + + it "should not delete model when cancel is clicked", -> + $('a.delete-section-button').click() + $('a.action-secondary').click() + expect(@requests.length).toEqual(0) + + # Fails sporadically in Jenkins. +# it "should show a confirmation on delete", -> +# $('a.delete-section-button').click() +# $('a.action-primary').click() +# expect(@notificationSpy).toHaveBeenCalled() + + describe "findDestination", -> + it "correctly finds the drop target of a drag", -> + $ele = $('#unit-1') + $ele.offset( + top: $ele.offset().top + 10, left: $ele.offset().left + ) + destination = OverviewDragger.findDestination($ele, 1) + expect(destination.ele).toBe($('#unit-2')) + expect(destination.attachMethod).toBe('before') + + it "can drag and drop across section boundaries, with special handling for first element", -> + $ele = $('#unit-1') + $ele.offset( + top: $('#unit-4').offset().top + 8 + left: $ele.offset().left + ) + destination = OverviewDragger.findDestination($ele, 1) + expect(destination.ele).toBe($('#unit-4')) + # Dragging down into first element, we have a fudge factor makes it easier to drag at beginning. + expect(destination.attachMethod).toBe('before') + # Now past the "fudge factor". + $ele.offset( + top: $('#unit-4').offset().top + 12 + left: $ele.offset().left + ) + destination = OverviewDragger.findDestination($ele, 1) + expect(destination.ele).toBe($('#unit-4')) + expect(destination.attachMethod).toBe('after') + + it "can drag and drop across section boundaries, with special handling for last element", -> + $ele = $('#unit-4') + $ele.offset( + top: $('#unit-3').offset().bottom + 4 + left: $ele.offset().left + ) + destination = OverviewDragger.findDestination($ele, -1) + expect(destination.ele).toBe($('#unit-3')) + # Dragging down up into last element, we have a fudge factor makes it easier to drag at beginning. + expect(destination.attachMethod).toBe('after') + # Now past the "fudge factor". + $ele.offset( + top: $('#unit-3').offset().top + 4 + left: $ele.offset().left + ) + destination = OverviewDragger.findDestination($ele, -1) + expect(destination.ele).toBe($('#unit-3')) + expect(destination.attachMethod).toBe('before') + + it "can drag into an empty list", -> + $ele = $('#unit-1') + $ele.offset( + top: $('#subsection-3').offset().top + 10 + left: $ele.offset().left + ) + destination = OverviewDragger.findDestination($ele, 1) + expect(destination.ele).toBe($('#subsection-list-3')) + expect(destination.attachMethod).toBe('prepend') + + it "reports a null destination on a failed drag", -> + $ele = $('#unit-1') + $ele.offset( + top: $ele.offset().top + 200, left: $ele.offset().left + ) + destination = OverviewDragger.findDestination($ele, 1) + expect(destination).toEqual( + ele: null + attachMethod: "" + ) + + it "can drag into a collapsed list", -> + $('#subsection-2').addClass('collapsed') + $ele = $('#unit-2') + $ele.offset( + top: $('#subsection-2').offset().top + 3 + left: $ele.offset().left + ) + destination = OverviewDragger.findDestination($ele, 1) + expect(destination.ele).toBe($('#subsection-list-2')) + expect(destination.parentList).toBe($('#subsection-2')) + expect(destination.attachMethod).toBe('prepend') + + describe "onDragStart", -> + it "sets the dragState to its default values", -> + expect(OverviewDragger.dragState).toEqual({}) + # Call with some dummy data + OverviewDragger.onDragStart( + {element: $('#unit-1')}, + null, + null + ) + expect(OverviewDragger.dragState).toEqual( + dropDestination: null, + attachMethod: '', + parentList: null, + lastY: 0, + dragDirection: 0 + ) + + it "collapses expanded elements", -> + expect($('#subsection-1')).not.toHaveClass('collapsed') + OverviewDragger.onDragStart( + {element: $('#subsection-1')}, + null, + null + ) + expect($('#subsection-1')).toHaveClass('collapsed') + expect($('#subsection-1')).toHaveClass('expand-on-drop') + + describe "onDragMove", -> + beforeEach -> + @scrollSpy = spyOn(window, 'scrollBy').andCallThrough() + + it "adds the correct CSS class to the drop destination", -> + $ele = $('#unit-1') + dragY = $ele.offset().top + 10 + dragX = $ele.offset().left + $ele.offset( + top: dragY, left: dragX + ) + OverviewDragger.onDragMove( + {element: $ele, dragPoint: + {y: dragY}}, '', {clientX: dragX} + ) + expect($('#unit-2')).toHaveClass('drop-target drop-target-before') + expect($ele).toHaveClass('valid-drop') + + it "does not add CSS class to the drop destination if out of bounds", -> + $ele = $('#unit-1') + dragY = $ele.offset().top + 10 + $ele.offset( + top: dragY, left: $ele.offset().left + ) + OverviewDragger.onDragMove( + {element: $ele, dragPoint: + {y: dragY}}, '', {clientX: $ele.offset().left - 3} + ) + expect($('#unit-2')).not.toHaveClass('drop-target drop-target-before') + expect($ele).not.toHaveClass('valid-drop') + + it "scrolls up if necessary", -> + OverviewDragger.onDragMove( + {element: $('#unit-1')}, '', {clientY: 2} + ) + expect(@scrollSpy).toHaveBeenCalledWith(0, -10) + + it "scrolls down if necessary", -> + OverviewDragger.onDragMove( + {element: $('#unit-1')}, '', {clientY: (window.innerHeight - 5)} + ) + expect(@scrollSpy).toHaveBeenCalledWith(0, 10) + + describe "onDragEnd", -> + beforeEach -> + @reorderSpy = spyOn(OverviewDragger, 'handleReorder') + + afterEach -> + @reorderSpy.reset() + + it "calls handleReorder on a successful drag", -> + OverviewDragger.dragState.dropDestination = $('#unit-2') + OverviewDragger.dragState.attachMethod = "before" + OverviewDragger.dragState.parentList = $('#subsection-1') + $('#unit-1').offset( + top: $('#unit-1').offset().top + 10 + left: $('#unit-1').offset().left + ) + OverviewDragger.onDragEnd( + {element: $('#unit-1')}, + null, + {clientX: $('#unit-1').offset().left} + ) + expect(@reorderSpy).toHaveBeenCalled() + + it "clears out the drag state", -> + OverviewDragger.onDragEnd( + {element: $('#unit-1')}, + null, + null + ) + expect(OverviewDragger.dragState).toEqual({}) + + it "sets the element to the correct position", -> + OverviewDragger.onDragEnd( + {element: $('#unit-1')}, + null, + null + ) + # Chrome sets the CSS to 'auto', but Firefox uses '0px'. + expect(['0px', 'auto']).toContain($('#unit-1').css('top')) + expect(['0px', 'auto']).toContain($('#unit-1').css('left')) + + it "expands an element if it was collapsed on drag start", -> + $('#subsection-1').addClass('collapsed') + $('#subsection-1').addClass('expand-on-drop') + OverviewDragger.onDragEnd( + {element: $('#subsection-1')}, + null, + null + ) + expect($('#subsection-1')).not.toHaveClass('collapsed') + expect($('#subsection-1')).not.toHaveClass('expand-on-drop') + + it "expands a collapsed element when something is dropped in it", -> + $('#subsection-2').addClass('collapsed') + OverviewDragger.dragState.dropDestination = $('#list-2') + OverviewDragger.dragState.attachMethod = "prepend" + OverviewDragger.dragState.parentList = $('#subsection-2') + OverviewDragger.onDragEnd( + {element: $('#unit-1')}, + null, + {clientX: $('#unit-1').offset().left} + ) + expect($('#subsection-2')).not.toHaveClass('collapsed') + + describe "AJAX", -> + beforeEach -> + @requests = requests = [] + @xhr = sinon.useFakeXMLHttpRequest() + @xhr.onCreate = (xhr) -> requests.push(xhr) + + @savingSpies = spyOnConstructor(Notification, "Mini", + ["show", "hide"]) + @savingSpies.show.andReturn(@savingSpies) + @clock = sinon.useFakeTimers() + + afterEach -> + @xhr.restore() + @clock.restore() + + it "should send an update on reorder", -> + OverviewDragger.dragState.dropDestination = $('#unit-4') + OverviewDragger.dragState.attachMethod = "after" + OverviewDragger.dragState.parentList = $('#subsection-2') + # Drag Unit 1 from Subsection 1 to the end of Subsection 2. + $('#unit-1').offset( + top: $('#unit-4').offset().top + 10 + left: $('#unit-4').offset().left + ) + OverviewDragger.onDragEnd( + {element: $('#unit-1')}, + null, + {clientX: $('#unit-1').offset().left} + ) + expect(@requests.length).toEqual(2) + expect(@savingSpies.constructor).toHaveBeenCalled() + expect(@savingSpies.show).toHaveBeenCalled() + expect(@savingSpies.hide).not.toHaveBeenCalled() + savingOptions = @savingSpies.constructor.mostRecentCall.args[0] + expect(savingOptions.title).toMatch(/Saving/) + expect($('#unit-1')).toHaveClass('was-dropped') + # We expect 2 requests to be sent-- the first for removing Unit 1 from Subsection 1, + # and the second for adding Unit 1 to the end of Subsection 2. + expect(@requests[0].requestBody).toEqual('{"id":"subsection-1-id","children":["second-unit-id","third-unit-id"]}') + @requests[0].respond(200) + expect(@savingSpies.hide).not.toHaveBeenCalled() + expect(@requests[1].requestBody).toEqual('{"id":"subsection-2-id","children":["fourth-unit-id","first-unit-id"]}') + @requests[1].respond(200) + expect(@savingSpies.hide).toHaveBeenCalled() + # Class is removed in a timeout. + @clock.tick(1001) + expect($('#unit-1')).not.toHaveClass('was-dropped') diff --git a/cms/static/coffee/src/main.coffee b/cms/static/coffee/src/main.coffee index 09253cbf40..c88b14fee4 100644 --- a/cms/static/coffee/src/main.coffee +++ b/cms/static/coffee/src/main.coffee @@ -1,5 +1,6 @@ -define ["jquery", "underscore.string", "backbone", "js/views/feedback_notification", "jquery.cookie"], -($, str, Backbone, NotificationView) -> +define ["domReady", "jquery", "underscore.string", "backbone", "gettext", + "js/views/feedback_notification", "jquery.cookie"], +(domReady, $, str, Backbone, gettext, NotificationView) -> AjaxPrefix.addAjaxPrefix jQuery, -> $("meta[name='path_prefix']").attr('content') @@ -36,5 +37,5 @@ define ["jquery", "underscore.string", "backbone", "js/views/feedback_notificati if onTouchBasedDevice() $('body').addClass 'touch-based-device' - $(main) + domReady(main) return main diff --git a/cms/static/coffee/src/views/module_edit.coffee b/cms/static/coffee/src/views/module_edit.coffee index 2faccdab26..041fc97c47 100644 --- a/cms/static/coffee/src/views/module_edit.coffee +++ b/cms/static/coffee/src/views/module_edit.coffee @@ -92,7 +92,6 @@ define ["backbone", "jquery", "underscore", "gettext", "xmodule", title: gettext('Saving…') saving.show() @model.save(data).done( => - # # showToastMessage("Your changes have been saved.", null, 3) @module = null @render() @$el.removeClass('editing') diff --git a/cms/static/js/base.js b/cms/static/js/base.js index 1f188026cb..4fd340ad7b 100644 --- a/cms/static/js/base.js +++ b/cms/static/js/base.js @@ -1,6 +1,6 @@ -require(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js/views/feedback_prompt", - "jquery.ui", "jquery.timepicker", "jquery.leanModal", "jquery.form", "jquery.smoothScroll"], - function($, _, gettext, NotificationView, PromptView) { +require(["domReady", "jquery", "underscore", "gettext", "js/views/feedback_notification", "js/views/feedback_prompt", + "js/utils/cancel_on_escape", "jquery.ui", "jquery.timepicker", "jquery.leanModal", "jquery.form", "jquery.smoothScroll"], + function(domReady, $, _, gettext, NotificationView, PromptView, CancelOnEscape) { var $body; var $modal; @@ -12,7 +12,7 @@ var $newComponentTypePicker; var $newComponentTemplatePickers; var $newComponentButton; -$(document).ready(function() { +domReady(function() { $body = $('body'); $modal = $('.history-modal'); $modalCover = $('.modal-cover'); @@ -94,9 +94,6 @@ $(document).ready(function() { // tender feedback window scrolling $('a.show-tender').bind('click', smoothScrollTop); - // toggling footer additional support - $('.cta-show-sock').bind('click', toggleSock); - // toggling overview section details $(function() { if ($('.courseware-section').length > 0) { @@ -126,15 +123,6 @@ $(document).ready(function() { $('.sync-date').bind('click', syncReleaseDate); - // import form setup - $('.view-import .file-input').bind('change', showImportSubmit); - $('.view-import .choose-file-button, .view-import .choose-file-button-inline').bind('click', function(e) { - e.preventDefault(); - $('.view-import .file-input').click(); - }); - - $('.new-course-button').bind('click', addNewCourse); - // section date setting $('.set-publish-date').bind('click', setSectionScheduleDate); $('.edit-section-start-cancel').bind('click', cancelSetSectionScheduleDate); @@ -221,20 +209,6 @@ function editSectionPublishDate(e) { $modalCover.show(); } -function showImportSubmit(e) { - var filepath = $(this).val(); - if (filepath.substr(filepath.length - 6, 6) == 'tar.gz') { - $('.error-block').hide(); - $('.file-name').html($(this).val().replace('C:\\fakepath\\', '')); - $('.file-name-block').show(); - $('.view-import .choose-file-button').hide(); - $('.submit-button').show(); - $('.progress').show(); - } else { - $('.error-block').html(gettext('File format not supported. Please upload a file with a tar.gz extension.')).show(); - } -} - function syncReleaseDate(e) { e.preventDefault(); $(this).closest('.notice').hide(); @@ -319,9 +293,6 @@ function saveSubsection() { success: function() { $spinner.delay(500).fadeOut(150); $changedInput = null; - }, - error: function() { - showToastMessage(gettext('There has been an error while saving your changes.')); } }); } @@ -423,31 +394,6 @@ function hideModal(e) { } } -function toggleSock(e) { - e.preventDefault(); - - var $btnLabel = $(this).find('.copy'); - var $sock = $('.wrapper-sock'); - var $sockContent = $sock.find('.wrapper-inner'); - - $sock.toggleClass('is-shown'); - $sockContent.toggle('fast'); - - $.smoothScroll({ - offset: -200, - easing: 'swing', - speed: 1000, - scrollElement: null, - scrollTarget: $sock - }); - - if ($sock.hasClass('is-shown')) { - $btnLabel.text(gettext('Hide Studio Help')); - } else { - $btnLabel.text(gettext('Looking for Help with Studio?')); - } -} - function toggleSubmodules(e) { e.preventDefault(); $(this).toggleClass('expand').toggleClass('collapse'); @@ -459,16 +405,6 @@ function setVisibility(e) { $(e.target).closest('.option').addClass('checked'); } -function editComponent(e) { - e.preventDefault(); - $(this).closest('.xmodule_edit').addClass('editing').find('.component-editor').slideDown(150); -} - -function closeComponentEditor(e) { - e.preventDefault(); - $(this).closest('.xmodule_edit').removeClass('editing').find('.component-editor').slideUp(150); -} - function showDateSetter(e) { e.preventDefault(); var $block = $(this).closest('.due-date-input'); @@ -497,41 +433,7 @@ function hideAlert(e) { $(this).closest('.wrapper-alert').removeClass('is-shown'); } -function showToastMessage(message, $button, lifespan) { - var $toast = $('
      '); - var $closeBtn = $('×'); - $toast.append($closeBtn); - var $content = $('
      '); - $content.html(message); - $toast.append($content); - if ($button) { - $button.addClass('action-button'); - $button.bind('click', hideToastMessage); - $content.append($button); - } - $closeBtn.bind('click', hideToastMessage); - - if ($('.toast-notification')[0]) { - var targetY = $('.toast-notification').offset().top + $('.toast-notification').outerHeight(); - $toast.css('top', (targetY + 10) + 'px'); - } - - $body.prepend($toast); - $toast.fadeIn(200); - - if (lifespan) { - $toast.timer = setTimeout(function() { - $toast.fadeOut(300); - }, lifespan * 1000); - } -} - -function hideToastMessage(e) { - e.preventDefault(); - $(this).closest('.toast-notification').remove(); -} - -function addNewSection(e, isTemplate) { +function addNewSection(e) { e.preventDefault(); $(e.target).addClass('disabled'); @@ -542,19 +444,9 @@ function addNewSection(e, isTemplate) { $newSection.find('.new-section-name').focus().select(); $newSection.find('.section-name-form').bind('submit', saveNewSection); $cancelButton.bind('click', cancelNewSection); - $body.bind('keyup', { - $cancelButton: $cancelButton - }, checkForCancel); + CancelOnEscape($cancelButton); } -function checkForCancel(e) { - if (e.which == 27) { - $body.unbind('keyup', checkForCancel); - e.data.$cancelButton.click(); - } -} - - function saveNewSection(e) { e.preventDefault(); @@ -571,7 +463,7 @@ function saveNewSection(e) { $.post('/create_item', { 'parent_location': parent, 'category': category, - 'display_name': display_name, + 'display_name': display_name }, function(data) { @@ -585,162 +477,6 @@ function cancelNewSection(e) { $(this).parents('section.new-section').remove(); } -function addNewCourse(e) { - e.preventDefault(); - $('.new-course-button').addClass('is-disabled'); - $('.new-course-save').addClass('is-disabled'); - var $newCourse = $('.wrapper-create-course').addClass('is-shown'); - var $cancelButton = $newCourse.find('.new-course-cancel'); - var $courseName = $('.new-course-name'); - $courseName.focus().select(); - $('.new-course-save').on('click', saveNewCourse); - $cancelButton.bind('click', cancelNewCourse); - $body.bind('keyup', { - $cancelButton: $cancelButton - }, checkForCancel); - - // Check that a course (org, number, run) doesn't use any special characters - var validateCourseItemEncoding = function(item) { - var required = validateRequiredField(item); - if(required) { - return required; - } - if(item !== encodeURIComponent(item)) { - return gettext('Please do not use any spaces or special characters in this field.'); - } - return ''; - }; - - // Ensure that org/course_num/run < 65 chars. - var validateTotalCourseItemsLength = function() { - var totalLength = _.reduce( - ['.new-course-org', '.new-course-number', '.new-course-run'], - function(sum, ele) { - return sum + $(ele).val().length; - }, 0 - ); - if(totalLength > 65) { - $('.wrap-error').addClass('is-shown'); - $('#course_creation_error').html('

      ' + gettext('The combined length of the organization, course number, and course run fields cannot be more than 65 characters.') + '

      '); - $('.new-course-save').addClass('is-disabled'); - } - else { - $('.wrap-error').removeClass('is-shown'); - } - }; - - // Handle validation asynchronously - _.each( - ['.new-course-org', '.new-course-number', '.new-course-run'], - function(ele) { - var $ele = $(ele); - $ele.on('keyup', function(event) { - // Don't bother showing "required field" error when - // the user tabs into a new field; this is distracting - // and unnecessary - if(event.keyCode === 9) { - return; - } - var error = validateCourseItemEncoding($ele.val()); - setNewCourseFieldInErr($ele.parent('li'), error); - validateTotalCourseItemsLength(); - }); - } - ); - var $name = $('.new-course-name'); - $name.on('keyup', function() { - var error = validateRequiredField($name.val()); - setNewCourseFieldInErr($name.parent('li'), error); - validateTotalCourseItemsLength(); - }); -} - -function validateRequiredField(msg) { - return msg.length === 0 ? gettext('Required field.') : ''; -} - -function setNewCourseFieldInErr(el, msg) { - if(msg) { - el.addClass('error'); - el.children('span.tip-error').addClass('is-showing').removeClass('is-hiding').text(msg); - $('.new-course-save').addClass('is-disabled'); - } - else { - el.removeClass('error'); - el.children('span.tip-error').addClass('is-hiding').removeClass('is-showing'); - // One "error" div is always present, but hidden or shown - if($('.error').length === 1) { - $('.new-course-save').removeClass('is-disabled'); - } - } -}; - -function saveNewCourse(e) { - e.preventDefault(); - - // One final check for empty values - var errors = _.reduce( - ['.new-course-name', '.new-course-org', '.new-course-number', '.new-course-run'], - function(acc, ele) { - var $ele = $(ele); - var error = validateRequiredField($ele.val()); - setNewCourseFieldInErr($ele.parent('li'), error); - return error ? true : acc; - }, - false - ); - - if(errors) { - return; - } - - var $newCourseForm = $(this).closest('#create-course-form'); - var display_name = $newCourseForm.find('.new-course-name').val(); - var org = $newCourseForm.find('.new-course-org').val(); - var number = $newCourseForm.find('.new-course-number').val(); - var run = $newCourseForm.find('.new-course-run').val(); - - analytics.track('Created a Course', { - 'org': org, - 'number': number, - 'display_name': display_name, - 'run': run - }); - - $.post('/create_new_course', { - 'org': org, - 'number': number, - 'display_name': display_name, - 'run': run - }, - function(data) { - if (data.id !== undefined) { - window.location = '/' + data.id.replace(/.*:\/\//, ''); - } else if (data.ErrMsg !== undefined) { - $('.wrap-error').addClass('is-shown'); - $('#course_creation_error').html('

      ' + data.ErrMsg + '

      '); - $('.new-course-save').addClass('is-disabled'); - } - } - ); -} - -function cancelNewCourse(e) { - e.preventDefault(); - $('.new-course-button').removeClass('is-disabled'); - $('.wrapper-create-course').removeClass('is-shown'); - // Clear out existing fields and errors - _.each( - ['.new-course-name', '.new-course-org', '.new-course-number', '.new-course-run'], - function(field) { - $(field).val(''); - } - ); - $('#course_creation_error').html(''); - $('.wrap-error').removeClass('is-shown'); - $('.new-course-save').off('click'); -} - function addNewSubsection(e) { e.preventDefault(); var $section = $(this).closest('.courseware-section'); @@ -758,9 +494,7 @@ function addNewSubsection(e) { $newSubsection.find('.new-subsection-form').bind('submit', saveNewSubsection); $cancelButton.bind('click', cancelNewSubsection); - $body.bind('keyup', { - $cancelButton: $cancelButton - }, checkForCancel); + CancelOnEscape($cancelButton); } function saveNewSubsection(e) { @@ -863,5 +597,8 @@ function saveSetSectionScheduleDate(e) { saving.hide(); }); } + // Add to window object for unit test (overview_spec). + window.saveSetSectionScheduleDate = saveSetSectionScheduleDate; + window.deleteSection = deleteSection; }); // end require() diff --git a/cms/static/js/hesitate.js b/cms/static/js/hesitate.js deleted file mode 100644 index a9c4be093b..0000000000 --- a/cms/static/js/hesitate.js +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Create a HesitateEvent and assign it as the event to execute: - * $(el).on('mouseEnter', CMS.HesitateEvent( expand, 'mouseLeave').trigger); - * It calls the executeOnTimeOut function with the event.currentTarget after the configurable timeout IFF the cancelSelector event - * did not occur on the event.currentTarget. - * - * More specifically, when trigger is called (triggered by the event you bound it to), it starts a timer - * which the cancelSelector event will cancel or if the timer finished, it executes the executeOnTimeOut function - * passing it the original event (whose currentTarget s/b the specific ele). It never accumulates events; however, it doesn't hurt for your - * code to minimize invocations of trigger by binding to mouseEnter v mouseOver and such. - * - * NOTE: if something outside of this wants to cancel the event, invoke cachedhesitation.untrigger(null | anything); - */ - -define(["jquery"], function($) { - var HesitateEvent = function(executeOnTimeOut, cancelSelector, onlyOnce) { - this.executeOnTimeOut = executeOnTimeOut; - this.cancelSelector = cancelSelector; - this.timeoutEventId = null; - this.originalEvent = null; - this.onlyOnce = (onlyOnce === true); - }; - - HesitateEvent.DURATION = 800; - - HesitateEvent.prototype.trigger = function(event) { - if (event.data.timeoutEventId == null) { - event.data.timeoutEventId = window.setTimeout( - function() { event.data.fireEvent(event); }, - HesitateEvent.DURATION); - event.data.originalEvent = event; - $(event.data.originalEvent.delegateTarget).on(event.data.cancelSelector, event.data, event.data.untrigger); - } - }; - - HesitateEvent.prototype.fireEvent = function(event) { - event.data.timeoutEventId = null; - $(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); - event.data.executeOnTimeOut(event.data.originalEvent); - }; - - HesitateEvent.prototype.untrigger = function(event) { - if (event.data.timeoutEventId) { - window.clearTimeout(event.data.timeoutEventId); - $(event.data.originalEvent.delegateTarget).off(event.data.cancelSelector, event.data.untrigger); - } - event.data.timeoutEventId = null; - }; - - return HesitateEvent; -}); diff --git a/cms/static/js/index.js b/cms/static/js/index.js new file mode 100644 index 0000000000..f2688f3dc0 --- /dev/null +++ b/cms/static/js/index.js @@ -0,0 +1,161 @@ +require(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape"], + function (domReady, $, _, CancelOnEscape) { + var saveNewCourse = function (e) { + e.preventDefault(); + + // One final check for empty values + var errors = _.reduce( + ['.new-course-name', '.new-course-org', '.new-course-number', '.new-course-run'], + function (acc, ele) { + var $ele = $(ele); + var error = validateRequiredField($ele.val()); + setNewCourseFieldInErr($ele.parent('li'), error); + return error ? true : acc; + }, + false + ); + + if (errors) { + return; + } + + var $newCourseForm = $(this).closest('#create-course-form'); + var display_name = $newCourseForm.find('.new-course-name').val(); + var org = $newCourseForm.find('.new-course-org').val(); + var number = $newCourseForm.find('.new-course-number').val(); + var run = $newCourseForm.find('.new-course-run').val(); + + analytics.track('Created a Course', { + 'org': org, + 'number': number, + 'display_name': display_name, + 'run': run + }); + + $.post('/create_new_course', { + 'org': org, + 'number': number, + 'display_name': display_name, + 'run': run + }, + function (data) { + if (data.id !== undefined) { + window.location = '/' + data.id.replace(/.*:\/\//, ''); + } else if (data.ErrMsg !== undefined) { + $('.wrap-error').addClass('is-shown'); + $('#course_creation_error').html('

      ' + data.ErrMsg + '

      '); + $('.new-course-save').addClass('is-disabled'); + } + } + ); + }; + + var cancelNewCourse = function (e) { + e.preventDefault(); + $('.new-course-button').removeClass('is-disabled'); + $('.wrapper-create-course').removeClass('is-shown'); + // Clear out existing fields and errors + _.each( + ['.new-course-name', '.new-course-org', '.new-course-number', '.new-course-run'], + function (field) { + $(field).val(''); + } + ); + $('#course_creation_error').html(''); + $('.wrap-error').removeClass('is-shown'); + $('.new-course-save').off('click'); + }; + + var addNewCourse = function (e) { + e.preventDefault(); + $('.new-course-button').addClass('is-disabled'); + $('.new-course-save').addClass('is-disabled'); + var $newCourse = $('.wrapper-create-course').addClass('is-shown'); + var $cancelButton = $newCourse.find('.new-course-cancel'); + var $courseName = $('.new-course-name'); + $courseName.focus().select(); + $('.new-course-save').on('click', saveNewCourse); + $cancelButton.bind('click', cancelNewCourse); + CancelOnEscape($cancelButton); + + // Check that a course (org, number, run) doesn't use any special characters + var validateCourseItemEncoding = function (item) { + var required = validateRequiredField(item); + if (required) { + return required; + } + if (item !== encodeURIComponent(item)) { + return gettext('Please do not use any spaces or special characters in this field.'); + } + return ''; + }; + + // Ensure that org/course_num/run < 65 chars. + var validateTotalCourseItemsLength = function () { + var totalLength = _.reduce( + ['.new-course-org', '.new-course-number', '.new-course-run'], + function (sum, ele) { + return sum + $(ele).val().length; + }, 0 + ); + if (totalLength > 65) { + $('.wrap-error').addClass('is-shown'); + $('#course_creation_error').html('

      ' + gettext('The combined length of the organization, course number, and course run fields cannot be more than 65 characters.') + '

      '); + $('.new-course-save').addClass('is-disabled'); + } + else { + $('.wrap-error').removeClass('is-shown'); + } + }; + + // Handle validation asynchronously + _.each( + ['.new-course-org', '.new-course-number', '.new-course-run'], + function (ele) { + var $ele = $(ele); + $ele.on('keyup', function (event) { + // Don't bother showing "required field" error when + // the user tabs into a new field; this is distracting + // and unnecessary + if (event.keyCode === 9) { + return; + } + var error = validateCourseItemEncoding($ele.val()); + setNewCourseFieldInErr($ele.parent('li'), error); + validateTotalCourseItemsLength(); + }); + } + ); + var $name = $('.new-course-name'); + $name.on('keyup', function () { + var error = validateRequiredField($name.val()); + setNewCourseFieldInErr($name.parent('li'), error); + validateTotalCourseItemsLength(); + }); + }; + + var validateRequiredField = function (msg) { + return msg.length === 0 ? gettext('Required field.') : ''; + }; + + var setNewCourseFieldInErr = function (el, msg) { + if(msg) { + el.addClass('error'); + el.children('span.tip-error').addClass('is-showing').removeClass('is-hiding').text(msg); + $('.new-course-save').addClass('is-disabled'); + } + else { + el.removeClass('error'); + el.children('span.tip-error').addClass('is-hiding').removeClass('is-showing'); + // One "error" div is always present, but hidden or shown + if($('.error').length === 1) { + $('.new-course-save').removeClass('is-disabled'); + } + } + }; + + + domReady(function () { + $('.new-course-button').bind('click', addNewCourse); + }); + }); diff --git a/cms/static/js/models/settings/course_grader.js b/cms/static/js/models/settings/course_grader.js index d04438bdff..a915c5f0ec 100644 --- a/cms/static/js/models/settings/course_grader.js +++ b/cms/static/js/models/settings/course_grader.js @@ -10,13 +10,13 @@ var CourseGrader = Backbone.Model.extend({ }, parse : function(attrs) { if (attrs['weight']) { - if (!_.isNumber(attrs.weight)) attrs.weight = parseInt(attrs.weight, 10); + attrs.weight = parseInt(attrs.weight, 10); } if (attrs['min_count']) { - if (!_.isNumber(attrs.min_count)) attrs.min_count = parseInt(attrs.min_count, 10); + 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); + attrs.drop_count = parseInt(attrs.drop_count, 10); } return attrs; }, diff --git a/cms/static/js/models/settings/course_grading_policy.js b/cms/static/js/models/settings/course_grading_policy.js index 7d16b61426..1e23a4ecf4 100644 --- a/cms/static/js/models/settings/course_grading_policy.js +++ b/cms/static/js/models/settings/course_grading_policy.js @@ -17,10 +17,10 @@ var CourseGradingPolicy = Backbone.Model.extend({ // interesting race condition: if {parse:true} when newing, then parse called before .attributes created if (this.attributes && this.has('graders')) { graderCollection = this.get('graders'); - graderCollection.reset(attributes.graders); + graderCollection.reset(attributes.graders, {parse:true}); } else { - graderCollection = new CourseGraderCollection(attributes.graders); + graderCollection = new CourseGraderCollection(attributes.graders, {parse:true}); graderCollection.course_location = attributes['course_location'] || this.get('course_location'); } attributes.graders = graderCollection; diff --git a/cms/static/js/sock.js b/cms/static/js/sock.js new file mode 100644 index 0000000000..0d8af7c0d0 --- /dev/null +++ b/cms/static/js/sock.js @@ -0,0 +1,32 @@ +require(["domReady", "jquery", "jquery.smoothScroll"], + function (domReady, $) { + var toggleSock = function (e) { + e.preventDefault(); + + var $btnLabel = $(this).find('.copy'); + var $sock = $('.wrapper-sock'); + var $sockContent = $sock.find('.wrapper-inner'); + + $sock.toggleClass('is-shown'); + $sockContent.toggle('fast'); + + $.smoothScroll({ + offset: -200, + easing: 'swing', + speed: 1000, + scrollElement: null, + scrollTarget: $sock + }); + + if ($sock.hasClass('is-shown')) { + $btnLabel.text(gettext('Hide Studio Help')); + } else { + $btnLabel.text(gettext('Looking for Help with Studio?')); + } + }; + + domReady(function () { + // toggling footer additional support + $('.cta-show-sock').bind('click', toggleSock); + }); + }); diff --git a/cms/static/js/utils/cancel_on_escape.js b/cms/static/js/utils/cancel_on_escape.js new file mode 100644 index 0000000000..febfc42bb4 --- /dev/null +++ b/cms/static/js/utils/cancel_on_escape.js @@ -0,0 +1,17 @@ +define(["jquery"], function($) { + var $body = $('body'); + var checkForCancel = function (e) { + if (e.which == 27) { + $body.unbind('keyup', checkForCancel); + e.data.$cancelButton.click(); + } + }; + + var cancelOnEscape = function (cancelButton) { + $body.bind('keyup', { + $cancelButton: cancelButton + }, checkForCancel); + }; + + return cancelOnEscape; +}); diff --git a/cms/static/js/views/course_info_update.js b/cms/static/js/views/course_info_update.js index 4fd6d9d5c4..256f63624a 100644 --- a/cms/static/js/views/course_info_update.js +++ b/cms/static/js/views/course_info_update.js @@ -2,7 +2,6 @@ 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: { @@ -18,6 +17,8 @@ define(["backbone", "underscore", "codemirror", "js/models/course_update", this.render(); // when the client refetches the updates as a whole, re-render them this.listenTo(this.collection, 'reset', this.render); + + this.$modalCover = $(".modal-cover"); }, render: function () { @@ -63,8 +64,8 @@ define(["backbone", "underscore", "codemirror", "js/models/course_update", $newForm.addClass('editing'); this.$currentPost = $newForm.closest('li'); - $modalCover.show(); - $modalCover.bind('click', function() { + this.$modalCover.show(); + this.$modalCover.bind('click', function() { self.closeEditor(true); }); @@ -120,9 +121,9 @@ define(["backbone", "underscore", "codemirror", "js/models/course_update", this.$codeMirror = CourseInfoHelper.editWithCodeMirror( targetModel, 'content', self.options['base_asset_url'], $textArea.get(0)); - $modalCover.show(); - $modalCover.bind('click', function() { - self.closeEditor(self); + this.$modalCover.show(); + this.$modalCover.bind('click', function() { + self.closeEditor(false); }); }, @@ -197,8 +198,8 @@ define(["backbone", "underscore", "codemirror", "js/models/course_update", this.$currentPost.find('.CodeMirror').remove(); } - $modalCover.unbind('click'); - $modalCover.hide(); + this.$modalCover.unbind('click'); + this.$modalCover.hide(); this.$codeMirror = null; }, diff --git a/cms/static/js/views/import.js b/cms/static/js/views/import.js index 14d2c6a22e..ca9d9bbe05 100644 --- a/cms/static/js/views/import.js +++ b/cms/static/js/views/import.js @@ -2,149 +2,172 @@ * Course import-related js. */ define( - ["jquery", "underscore", "gettext"], - function($, _, gettext) { + ["domReady", "jquery", "underscore", "gettext"], + function(domReady, $, _, gettext) { -"use strict"; + "use strict"; -/********** Private functions ************************************************/ + /********** Private functions ************************************************/ -/** - * Toggle the spin on the progress cog. - * @param {boolean} isSpinning Turns cog spin on if true, off otherwise. - */ -var updateCog = function (elem, isSpinning) { - var cogI = elem.find('i.icon-cog'); - if (isSpinning) { cogI.addClass("icon-spin");} - else { cogI.removeClass("icon-spin");} -}; + /** + * Toggle the spin on the progress cog. + * @param {boolean} isSpinning Turns cog spin on if true, off otherwise. + */ + var updateCog = function (elem, isSpinning) { + var cogI = elem.find('i.icon-cog'); + if (isSpinning) { cogI.addClass("icon-spin");} + else { cogI.removeClass("icon-spin");} + }; -/** - * Manipulate the DOM to reflect current status of upload. - * @param {int} stageNo Current stage. - */ -var updateStage = function (stageNo){ - var all = $('ol.status-progress').children(); - var prevList = all.slice(0, stageNo); - _.map(prevList, function (elem){ - $(elem). - removeClass("is-not-started"). - removeClass("is-started"). - addClass("is-complete"); - updateCog($(elem), false); + /** + * Manipulate the DOM to reflect current status of upload. + * @param {int} stageNo Current stage. + */ + var updateStage = function (stageNo){ + var all = $('ol.status-progress').children(); + var prevList = all.slice(0, stageNo); + _.map(prevList, function (elem){ + $(elem). + removeClass("is-not-started"). + removeClass("is-started"). + addClass("is-complete"); + updateCog($(elem), false); + }); + var curList = all.eq(stageNo); + curList.removeClass("is-not-started").addClass("is-started"); + updateCog(curList, true); + }; + + /** + * Check for import status updates every `timeout` milliseconds, and update + * the page accordingly. + * @param {string} url Url to call for status updates. + * @param {int} timeout Number of milliseconds to wait in between ajax calls + * for new updates. + * @param {int} stage Starting stage. + */ + var getStatus = function (url, timeout, stage) { + var currentStage = stage || 0; + if (CourseImport.stopGetStatus) { return ;} + updateStage(currentStage); + if (currentStage == 3 ) { return ;} + var time = timeout || 1000; + $.getJSON(url, + function (data) { + setTimeout(function () { + getStatus(url, time, data.ImportStatus); + }, time); + } + ); + }; + + + + /********** Public functions *************************************************/ + + var CourseImport = { + + /** + * Whether to stop sending AJAX requests for updates on the import + * progress. + */ + stopGetStatus: false, + + /** + * Update DOM to set all stages as not-started (for retrying an upload that + * failed). + */ + clearImportDisplay: function () { + var all = $('ol.status-progress').children(); + _.map(all, function (elem){ + $(elem).removeClass("is-complete"). + removeClass("is-started"). + removeClass("has-error"). + addClass("is-not-started"); + $(elem).find('p.error').remove(); // remove error messages + $(elem).find('p.copy').show(); + updateCog($(elem), false); + }); + this.stopGetStatus = false; + }, + + /** + * Update DOM to set all stages as complete, and stop asking for status + * updates. + */ + displayFinishedImport: function () { + this.stopGetStatus = true; + var all = $('ol.status-progress').children(); + _.map(all, function (elem){ + $(elem). + removeClass("is-not-started"). + removeClass("is-started"). + addClass("is-complete"); + updateCog($(elem), false); + }); + }, + + /** + * Entry point for server feedback. Makes status list visible and starts + * sending requests to the server for status updates. + * @param {string} url The url to send Ajax GET requests for updates. + */ + startServerFeedback: function (url){ + this.stopGetStatus = false; + $('div.wrapper-status').removeClass('is-hidden'); + $('.status-info').show(); + getStatus(url, 500, 0); + }, + + + /** + * Give error message at the list element that corresponds to the stage + * where the error occurred. + * @param {int} stageNo Stage of import process at which error occured. + * @param {string} msg Error message to display. + */ + stageError: function (stageNo, msg) { + var all = $('ol.status-progress').children(); + // Make all stages up to, and including, the error stage 'complete'. + var prevList = all.slice(0, stageNo + 1); + _.map(prevList, function (elem){ + $(elem). + removeClass("is-not-started"). + removeClass("is-started"). + addClass("is-complete"); + updateCog($(elem), false); + }); + var message = msg || gettext("There was an error with the upload"); + var elem = $('ol.status-progress').children().eq(stageNo); + elem.removeClass('is-started').addClass('has-error'); + elem.find('p.copy').hide().after("

      " + message + "

      "); + } + + }; + + var showImportSubmit = function (e) { + var filepath = $(this).val(); + if (filepath.substr(filepath.length - 6, 6) == 'tar.gz') { + $('.error-block').hide(); + $('.file-name').html($(this).val().replace('C:\\fakepath\\', '')); + $('.file-name-block').show(); + $('.view-import .choose-file-button').hide(); + $('.submit-button').show(); + $('.progress').show(); + } else { + $('.error-block').html(gettext('File format not supported. Please upload a file with a tar.gz extension.')).show(); + } + }; + + domReady(function () { + // import form setup + $('.view-import .file-input').bind('change', showImportSubmit); + $('.view-import .choose-file-button, .view-import .choose-file-button-inline').bind('click', function (e) { + e.preventDefault(); + $('.view-import .file-input').click(); + }); + }); + + return CourseImport; }); - var curList = all.eq(stageNo); - curList.removeClass("is-not-started").addClass("is-started"); - updateCog(curList, true); -}; - -/** - * Check for import status updates every `timemout` milliseconds, and update - * the page accordingly. - * @param {string} url Url to call for status updates. - * @param {int} timeout Number of milliseconds to wait in between ajax calls - * for new updates. - * @param {int} stage Starting stage. - */ -var getStatus = function (url, timeout, stage) { - var currentStage = stage || 0; - if (CourseImport.stopGetStatus) { return ;} - updateStage(currentStage); - if (currentStage == 3 ) { return ;} - var time = timeout || 1000; - $.getJSON(url, - function (data) { - setTimeout(function () { - getStatus(url, time, data.ImportStatus); - }, time); - } - ); -}; - - - -/********** Public functions *************************************************/ - -var CourseImport = { - - /** - * Whether to stop sending AJAX requests for updates on the import - * progress. - */ - stopGetStatus: false, - - /** - * Update DOM to set all stages as not-started (for retrying an upload that - * failed). - */ - clearImportDisplay: function () { - var all = $('ol.status-progress').children(); - _.map(all, function (elem){ - $(elem).removeClass("is-complete"). - removeClass("is-started"). - removeClass("has-error"). - addClass("is-not-started"); - $(elem).find('p.error').remove(); // remove error messages - $(elem).find('p.copy').show(); - updateCog($(elem), false); - }); - this.stopGetStatus = false; - }, - - /** - * Update DOM to set all stages as complete, and stop asking for status - * updates. - */ - displayFinishedImport: function () { - this.stopGetStatus = true; - var all = $('ol.status-progress').children(); - _.map(all, function (elem){ - $(elem). - removeClass("is-not-started"). - removeClass("is-started"). - addClass("is-complete"); - updateCog($(elem), false); - }); - }, - - /** - * Entry point for server feedback. Makes status list visible and starts - * sending requests to the server for status updates. - * @param {string} url The url to send Ajax GET requests for updates. - */ - startServerFeedback: function (url){ - this.stopGetStatus = false; - $('div.wrapper-status').removeClass('is-hidden'); - $('.status-info').show(); - getStatus(url, 500, 0); - }, - - - /** - * Give error message at the list element that corresponds to the stage - * where the error occurred. - * @param {int} stageNo Stage of import process at which error occured. - * @param {string} msg Error message to display. - */ - stageError: function (stageNo, msg) { - var all = $('ol.status-progress').children(); - // Make all stages up to, and including, the error stage 'complete'. - var prevList = all.slice(0, stageNo + 1); - _.map(prevList, function (elem){ - $(elem). - removeClass("is-not-started"). - removeClass("is-started"). - addClass("is-complete"); - updateCog($(elem), false); - }); - var message = msg || gettext("There was an error with the upload"); - var elem = $('ol.status-progress').children().eq(stageNo); - elem.removeClass('is-started').addClass('has-error'); - elem.find('p.copy').hide().after("

      " + message + "

      "); - } - -}; - -return CourseImport; -}); diff --git a/cms/static/js/views/overview.js b/cms/static/js/views/overview.js index 7dc2e48586..6e18dfdee2 100644 --- a/cms/static/js/views/overview.js +++ b/cms/static/js/views/overview.js @@ -1,248 +1,322 @@ -require(["jquery", "jquery.ui", "gettext", "js/hesitate", "js/views/feedback_notification"], - function($, ui, gettext, HesitateEvent, NotificationView) { +define(["domReady", "jquery", "jquery.ui", "gettext", "js/views/feedback_notification", "draggabilly"], + function (domReady, $, ui, gettext, NotificationView, Draggabilly) { -$(document).ready(function() { - // making the unit list draggable. Note: sortable didn't work b/c it considered - // drop points which the user hovered over as destinations and proactively changed - // the dom; so, if the user subsequently dropped at an illegal spot, the reversion - // point was the last dom change. - $('.unit').draggable({ - axis: 'y', - handle: '.drag-handle', - zIndex: 999, - start: initiateHesitate, - // left 2nd arg in as inert selector b/c i was uncertain whether we'd try to get the shove up/down - // to work in the future - drag: generateCheckHoverState('.collapsed', ''), - stop: removeHesitate, - revert: "invalid" - }); + var overviewDragger = { + droppableClasses: 'drop-target drop-target-prepend drop-target-before drop-target-after', + validDropClass: "valid-drop", + expandOnDropClass: "expand-on-drop", - // Subsection reordering - $('.id-holder').draggable({ - axis: 'y', - handle: '.section-item .drag-handle', - zIndex: 999, - start: initiateHesitate, - drag: generateCheckHoverState('.courseware-section.collapsed', ''), - stop: removeHesitate, - revert: "invalid" - }); + /* + * Determine information about where to drop the currently dragged + * element. Returns the element to attach to and the method of + * attachment ('before', 'after', or 'prepend'). + */ + findDestination: function (ele, yChange) { + var eleY = ele.offset().top; + var containers = $(ele.data('droppable-class')); - // Section reordering - $('.courseware-section').draggable({ - axis: 'y', - handle: 'header .drag-handle', - stack: '.courseware-section', - revert: "invalid" - }); + for (var i = 0; i < containers.length; i++) { + var container = $(containers[i]); + // Exclude the 'new unit' buttons, and make sure we don't + // prepend an element to itself + var siblings = container.children().filter(function () { + return $(this).data('id') !== undefined && !$(this).is(ele); + }); + // If the container is collapsed, check to see if the + // element is on top of its parent list -- don't check the + // position of the container + var parentList = container.parents(ele.data('parent-location-selector')).first(); + if (parentList.hasClass('collapsed')) { + if (Math.abs(eleY - parentList.offset().top) < 10) { + return { + ele: container, + attachMethod: 'prepend', + parentList: parentList + }; + } + } + // Otherwise, do check the container + else { + // If the list is empty, we should prepend to it, + // unless both elements are at the same location -- + // this prevents the user from being unable to expand + // a section + var containerY = container.offset().top; + if (siblings.length == 0 && + containerY != eleY && + Math.abs(eleY - containerY) < 50) { + return { + ele: container, + attachMethod: 'prepend' + }; + } + // Otherwise the list is populated, and we should attach before/after a sibling + else { + for (var j = 0; j < siblings.length; j++) { + var $sibling = $(siblings[j]); + var siblingY = $sibling.offset().top; + var siblingHeight = $sibling.height(); + var siblingYEnd = siblingY + siblingHeight; - - $('.sortable-unit-list').droppable({ - accept : '.unit', - greedy: true, - tolerance: "pointer", - hoverClass: "dropover", - drop: onUnitReordered - }); - $('.subsection-list > ol').droppable({ - // why don't we have a more useful class for subsections than id-holder? - accept : '.id-holder', // '.unit, .id-holder', - tolerance: "pointer", - hoverClass: "dropover", - drop: onSubsectionReordered, - greedy: true - }); - - // Section reordering - $('.courseware-overview').droppable({ - accept : '.courseware-section', - tolerance: "pointer", - drop: onSectionReordered, - greedy: true - }); - - // stop clicks on drag bars from doing their thing w/o stopping drag - $('.drag-handle').click(function(e) {e.preventDefault(); }); - -}); - -HesitateEvent.toggleXpandHesitation = null; -function initiateHesitate(event, ui) { - HesitateEvent.toggleXpandHesitation = new HesitateEvent(expandSection, 'dragLeave', true); - $('.collapsed').on('dragEnter', HesitateEvent.toggleXpandHesitation, HesitateEvent.toggleXpandHesitation.trigger); - $('.collapsed, .unit, .id-holder').each(function() { - this.proportions = {width : this.offsetWidth, height : this.offsetHeight }; - // reset b/c these were holding values from aborts - this.isover = false; - }); -} - -function computeIntersection(droppable, uiHelper, y) { - /* - * Test whether y falls within the bounds of the droppable on the Y axis - */ - // NOTE: this only judges y axis intersection b/c that's all we're doing right now - // don't expand the thing being carried - if (uiHelper.is(droppable)) { - return null; - } - - $.extend(droppable, {offset : $(droppable).offset()}); - - var t = droppable.offset.top, - b = t + droppable.proportions.height; - - if (t === b) { - // probably wrong values b/c invisible at the time of caching - droppable.proportions = { width : droppable.offsetWidth, height : droppable.offsetHeight }; - b = t + droppable.proportions.height; - } - // equivalent to the intersects test - return (t < y && // Bottom Half - y < b ); // Top Half -} - -// NOTE: selectorsToShove is not currently being used but I left this code as it did work but not well -function generateCheckHoverState(selectorsToOpen, selectorsToShove) { - return function(event, ui) { - // copied from jquery.ui.droppable.js $.ui.ddmanager.drag & other ui.intersect - var draggable = $(this).data("ui-draggable"), - centerY = (draggable.positionAbs || draggable.position.absolute).top + (draggable.helperProportions.height / 2); - $(selectorsToOpen).each(function() { - var intersects = computeIntersection(this, ui.helper, centerY), - c = !intersects && this.isover ? "isout" : (intersects && !this.isover ? "isover" : null); - - if(!c) { - return; - } - - this[c] = true; - this[c === "isout" ? "isover" : "isout"] = false; - $(this).trigger(c === "isover" ? "dragEnter" : "dragLeave"); - }); - - $(selectorsToShove).each(function() { - var intersectsBottom = computeIntersection(this, ui.helper, (draggable.positionAbs || draggable.position.absolute).top); - - if ($(this).hasClass('ui-dragging-pushup')) { - if (!intersectsBottom) { - console.log('not up', $(this).data('id')); - $(this).removeClass('ui-dragging-pushup'); - } - } - else if (intersectsBottom) { - console.log('up', $(this).data('id')); - $(this).addClass('ui-dragging-pushup'); - } - - var intersectsTop = computeIntersection(this, ui.helper, - (draggable.positionAbs || draggable.position.absolute).top + draggable.helperProportions.height); - - if ($(this).hasClass('ui-dragging-pushdown')) { - if (!intersectsTop) { - console.log('not down', $(this).data('id')); - $(this).removeClass('ui-dragging-pushdown'); + // Facilitate dropping into the beginning or end of a list + // (coming from opposite direction) via a "fudge factor". Math.min is for Jasmine test. + var fudge = Math.min(Math.ceil(siblingHeight / 2), 20); + // Dragging up into end of list. + if (j == siblings.length - 1 && yChange < 0 && Math.abs(eleY - siblingYEnd) <= fudge) { + return { + ele: $sibling, + attachMethod: 'after' + }; + } + // Dragging down into beginning of list. + else if (j == 0 && yChange > 0 && Math.abs(eleY - siblingY) <= fudge) { + return { + ele: $sibling, + attachMethod: 'before' + }; + } + else if (eleY >= siblingY && eleY <= siblingYEnd) { + return { + ele: $sibling, + attachMethod: eleY - siblingY <= siblingHeight / 2 ? 'before' : 'after' + }; + } + } + } + } } - } - else if (intersectsTop) { - console.log('down', $(this).data('id')); - $(this).addClass('ui-dragging-pushdown'); - } + // Failed drag + return { + ele: null, + attachMethod: '' + } + }, - }); - }; -} + // Information about the current drag. + dragState: {}, -function removeHesitate(event, ui) { - $('.collapsed').off('dragEnter', HesitateEvent.toggleXpandHesitation.trigger); - $('.ui-dragging-pushdown').removeClass('ui-dragging-pushdown'); - $('.ui-dragging-pushup').removeClass('ui-dragging-pushup'); - HesitateEvent.toggleXpandHesitation = null; -} + onDragStart: function (draggie, event, pointer) { + var ele = $(draggie.element); + this.dragState = { + // Which element will be dropped into/onto on success + dropDestination: null, + // How we attach to the destination: 'before', 'after', 'prepend' + attachMethod: '', + // If dragging to an empty section, the parent section + parentList: null, + // The y location of the last dragMove event (to determine direction). + lastY: 0, + // The direction the drag is moving in (negative means up, positive down). + dragDirection: 0 + }; + if (!ele.hasClass('collapsed')) { + ele.addClass('collapsed'); + ele.find('.expand-collapse-icon').first().addClass('expand').removeClass('collapse'); + // onDragStart gets called again after the collapse, so we can't just store a variable in the dragState. + ele.addClass(this.expandOnDropClass); + } + }, -function expandSection(event) { - $(event.delegateTarget).removeClass('collapsed', 400); - // don't descend to icon's on children (which aren't under first child) only to this element's icon - $(event.delegateTarget).children().first().find('.expand-collapse-icon').removeClass('expand', 400).addClass('collapse'); -} + onDragMove: function (draggie, event, pointer) { + // Handle scrolling of the browser. + var scrollAmount = 0; + var dragBuffer = 10; + if (window.innerHeight - dragBuffer < pointer.clientY) { + scrollAmount = dragBuffer; + } + else if (dragBuffer > pointer.clientY) { + scrollAmount = -(dragBuffer); + } + if (scrollAmount !== 0) { + window.scrollBy(0, scrollAmount); + return; + } -function onUnitReordered(event, ui) { - // a unit's been dropped on this subsection, - // figure out where it came from and where it slots in. - _handleReorder(event, ui, 'subsection-id', 'li:.leaf'); -} + var yChange = draggie.dragPoint.y - this.dragState.lastY; + if (yChange !== 0) { + this.dragState.direction = yChange; + } + this.dragState.lastY = draggie.dragPoint.y; -function onSubsectionReordered(event, ui) { - // a subsection has been dropped on this section, - // figure out where it came from and where it slots in. - _handleReorder(event, ui, 'section-id', 'li:.branch'); -} + var ele = $(draggie.element); + var destinationInfo = this.findDestination(ele, this.dragState.direction); + var destinationEle = destinationInfo.ele; + this.dragState.parentList = destinationInfo.parentList; -function onSectionReordered(event, ui) { - // a section moved w/in the overall (cannot change course via this, so no parentage change possible, just order) - _handleReorder(event, ui, 'course-id', '.courseware-section'); -} + // Clear out the old destination + if (this.dragState.dropDestination) { + this.dragState.dropDestination.removeClass(this.droppableClasses); + } + // Mark the new destination + if (destinationEle && this.pointerInBounds(pointer, ele)) { + ele.addClass(this.validDropClass); + destinationEle.addClass('drop-target drop-target-' + destinationInfo.attachMethod); + this.dragState.attachMethod = destinationInfo.attachMethod; + this.dragState.dropDestination = destinationEle; + } + else { + ele.removeClass(this.validDropClass); + this.dragState.attachMethod = ''; + this.dragState.dropDestination = null; + } + }, -function _handleReorder(event, ui, parentIdField, childrenSelector) { - // figure out where it came from and where it slots in. - var subsection_id = $(event.target).data(parentIdField); - var _els = $(event.target).children(childrenSelector); - var children = _els.map(function(idx, el) { return $(el).data('id'); }).get(); - // if new to this parent, figure out which parent to remove it from and do so - if (!_.contains(children, ui.draggable.data('id'))) { - var old_parent = ui.draggable.parent(); - var old_children = old_parent.children(childrenSelector).map(function(idx, el) { return $(el).data('id'); }).get(); - old_children = _.without(old_children, ui.draggable.data('id')); - $.ajax({ - url: "/save_item", - type: "POST", - dataType: "json", - contentType: "application/json", - data:JSON.stringify({ 'id' : old_parent.data(parentIdField), 'children' : old_children}) + onDragEnd: function (draggie, event, pointer) { + var ele = $(draggie.element); + var destination = this.dragState.dropDestination; + + // Clear dragging state in preparation for the next event. + if (destination) { + destination.removeClass(this.droppableClasses); + } + ele.removeClass(this.validDropClass); + + // If the drag succeeded, rearrange the DOM and send the result. + if (destination && this.pointerInBounds(pointer, ele)) { + // Make sure we don't drop into a collapsed element + if (this.dragState.parentList) { + this.expandElement(this.dragState.parentList); + } + var method = this.dragState.attachMethod; + destination[method](ele); + this.handleReorder(ele); + } + // If the drag failed, send it back + else { + $('.was-dragging').removeClass('was-dragging'); + ele.addClass('was-dragging'); + } + + if (ele.hasClass(this.expandOnDropClass)) { + this.expandElement(ele); + ele.removeClass(this.expandOnDropClass); + } + + // Everything in its right place + ele.css({ + top: 'auto', + left: 'auto' }); - } - else { - // staying in same parent - // remove so that the replacement in the right place doesn't double it - children = _.without(children, ui.draggable.data('id')); - } - // add to this parent (figure out where) - for (var i = 0, bump = 0; i < _els.length; i++) { - if (ui.draggable.is(_els[i])) { - bump = -1; // bump indicates that the draggable was passed in the dom but not children's list b/c - // it's not in that list - } - else if (ui.offset.top < $(_els[i]).offset().top) { - // insert at i in children and _els - ui.draggable.insertBefore($(_els[i])); - // TODO figure out correct way to have it remove the style: top:n; setting (and similar line below) - ui.draggable.attr("style", "position:relative;"); - children.splice(i + bump, 0, ui.draggable.data('id')); - break; + + this.dragState = {}; + }, + + pointerInBounds: function (pointer, ele) { + return pointer.clientX >= ele.offset().left && pointer.clientX < ele.offset().left + ele.width(); + }, + + expandElement: function (ele) { + ele.removeClass('collapsed'); + ele.find('.expand-collapse-icon').first().removeClass('expand').addClass('collapse'); + }, + + /* + * Find all parent-child changes and save them. + */ + handleReorder: function (ele) { + var parentSelector = ele.data('parent-location-selector'); + var childrenSelector = ele.data('child-selector'); + var newParentEle = ele.parents(parentSelector).first(); + var newParentID = newParentEle.data('id'); + var oldParentID = ele.data('parent-id'); + // If the parent has changed, update the children of the old parent. + if (oldParentID !== newParentID) { + // Find the old parent element. + var oldParentEle = $(parentSelector).filter(function () { + return $(this).data('id') === oldParentID; + }); + this.saveItem(oldParentEle, childrenSelector, function () { + ele.data('parent-id', newParentID); + }); } - } - // see if it goes at end (the above loop didn't insert it) - if (!_.contains(children, ui.draggable.data('id'))) { - $(event.target).append(ui.draggable); - ui.draggable.attr("style", "position:relative;"); // STYLE hack too - children.push(ui.draggable.data('id')); - } - var saving = new NotificationView.Mini({ - title: gettext('Saving…') - }); - saving.show(); - $.ajax({ - url: "/save_item", - type: "POST", - dataType: "json", - contentType: "application/json", - data:JSON.stringify({ 'id' : subsection_id, 'children' : children}), - success: function() { + var saving = new NotificationView.Mini({ + title: gettext('Saving…') + }); + saving.show(); + ele.addClass('was-dropped'); + // Timeout interval has to match what is in the CSS. + setTimeout(function () { + ele.removeClass('was-dropped'); + }, 1000); + this.saveItem(newParentEle, childrenSelector, function () { saving.hide(); - } + }); + }, + + /* + * Actually save the update to the server. Takes the element + * representing the parent item to save, a CSS selector to find + * its children, and a success callback. + */ + saveItem: function (ele, childrenSelector, success) { + // Find all current child IDs. + var children = _.map( + ele.find(childrenSelector), + function (child) { + return $(child).data('id'); + } + ); + $.ajax({ + url: '/save_item', + type: 'POST', + dataType: 'json', + contentType: 'application/json', + data: JSON.stringify({ + id: ele.data('id'), + children: children + }), + success: success + }); + }, + + /* + * Make `type` draggable using `handleClass`, able to be dropped + * into `droppableClass`, and with parent type + * `parentLocationSelector`. + */ + makeDraggable: function (type, handleClass, droppableClass, parentLocationSelector) { + _.each( + $(type), + function (ele) { + // Remember data necessary to reconstruct the parent-child relationships + $(ele).data('droppable-class', droppableClass); + $(ele).data('parent-location-selector', parentLocationSelector); + $(ele).data('child-selector', type); + var draggable = new Draggabilly(ele, { + handle: handleClass, + containment: '.wrapper-dnd' + }); + draggable.on('dragStart', _.bind(overviewDragger.onDragStart, overviewDragger)); + draggable.on('dragMove', _.bind(overviewDragger.onDragMove, overviewDragger)); + draggable.on('dragEnd', _.bind(overviewDragger.onDragEnd, overviewDragger)); + } + ); + } + }; + + domReady(function() { + // Section + overviewDragger.makeDraggable( + '.courseware-section', + '.section-drag-handle', + '.courseware-overview', + 'article.courseware-overview' + ); + // Subsection + overviewDragger.makeDraggable( + '.id-holder', + '.subsection-drag-handle', + '.subsection-list > ol', + '.courseware-section' + ); + // Unit + overviewDragger.makeDraggable( + '.unit', + '.unit-drag-handle', + 'ol.sortable-unit-list', + 'li.branch, article.subsection-body' + ); }); -} - -}); // end define() + return overviewDragger; + }); diff --git a/cms/static/js/views/settings/grading.js b/cms/static/js/views/settings/grading.js index 23393816cf..9b74be92a2 100644 --- a/cms/static/js/views/settings/grading.js +++ b/cms/static/js/views/settings/grading.js @@ -1,5 +1,5 @@ -define(["js/views/validation", "underscore", "jquery", "js/views/settings/grader"], - function(ValidatingView, _, $, GraderView) { +define(["js/views/validation", "underscore", "jquery", "jquery.ui", "js/views/settings/grader"], + function(ValidatingView, _, $, ui, GraderView) { var GradingView = ValidatingView.extend({ // Model class is CMS.Models.Settings.CourseGradingPolicy diff --git a/cms/static/js_test.yml b/cms/static/js_test.yml index f44d9dbbda..0e163b61d1 100644 --- a/cms/static/js_test.yml +++ b/cms/static/js_test.yml @@ -28,7 +28,6 @@ prepend_path: cms/static # Paths to library JavaScript files (optional) lib_paths: - - coffee/spec/setup_require.js - xmodule_js/common_static/js/vendor/require.js - xmodule_js/common_static/coffee/src/ajax_prefix.js - xmodule_js/common_static/js/src/utility.js @@ -51,6 +50,10 @@ lib_paths: - xmodule_js/common_static/js/vendor/CodeMirror/codemirror.js - xmodule_js/src/xmodule.js - xmodule_js/common_static/js/test/i18n.js + - xmodule_js/common_static/js/vendor/draggabilly.pkgd.js + - xmodule_js/common_static/js/vendor/date.js + - xmodule_js/common_static/js/vendor/domReady.js + - xmodule_js/common_static/js/vendor/jquery.smooth-scroll.min.js # Paths to source JavaScript files src_paths: diff --git a/cms/static/js_test_squire.yml b/cms/static/js_test_squire.yml index ca63d5d41a..2284319997 100644 --- a/cms/static/js_test_squire.yml +++ b/cms/static/js_test_squire.yml @@ -28,7 +28,6 @@ prepend_path: cms/static # Paths to library JavaScript files (optional) lib_paths: - - coffee/spec/setup_require.js - xmodule_js/common_static/js/vendor/require.js - xmodule_js/common_static/coffee/src/ajax_prefix.js - xmodule_js/common_static/js/src/utility.js diff --git a/cms/static/sass/_base.scss b/cms/static/sass/_base.scss index 93c2c0fa6f..7735a83297 100644 --- a/cms/static/sass/_base.scss +++ b/cms/static/sass/_base.scss @@ -528,9 +528,9 @@ p, ul, ol, dl { .new-subsection-item, .new-policy-item { @include grey-button; - margin: 5px 8px; - padding: 3px 10px 4px 10px; - font-size: 10px; + @include font-size(10); + margin: ($baseline/2); + padding: 3px ($baseline/2) 4px ($baseline/2); .new-folder-icon, .new-policy-icon, diff --git a/cms/static/sass/_mixins-inherited.scss b/cms/static/sass/_mixins-inherited.scss new file mode 120000 index 0000000000..f64a720561 --- /dev/null +++ b/cms/static/sass/_mixins-inherited.scss @@ -0,0 +1 @@ +../../../common/static/sass/_mixins-inherited.scss \ No newline at end of file diff --git a/cms/static/sass/_shame.scss b/cms/static/sass/_shame.scss index 85d133af1f..3c765bea93 100644 --- a/cms/static/sass/_shame.scss +++ b/cms/static/sass/_shame.scss @@ -3,7 +3,7 @@ // ==================== // view - dashboard -body.dashboard { +.view-dashboard { // elements - authorship controls .wrapper-authorshiprights { @@ -22,6 +22,35 @@ body.dashboard { } } +// ==================== + +.view-unit { + + .unit-location .draggable-drop-indicator { + display: none; //needed to not show DnD UI (UI is shared across both views) + } +} + +// ==================== + +// needed to override ui-window styling for dragging state (outline selectors get too specific) +.courseware-section.is-dragging { + box-shadow: 0 1px 2px 0 $shadow-d1 !important; + border: 1px solid $gray-d3 !important; +} + +.courseware-section.is-dragging.valid-drop { + border-color: $blue-s1 !important; + box-shadow: 0 1px 2px 0 $blue-t2 !important; +} + +// ==================== + +// needed for poorly scoped margin rules on all content elements +.branch .sortable-unit-list { + margin-bottom: 0; +} + // yes we have no boldness today - need to fix the resets body strong, @@ -29,12 +58,13 @@ body b { font-weight: 700; } -// known things to do (paint the fence, sand the floor, wax on/off) // ==================== -// known things to do (paint the fence, sand the floor, wax on/off): +/* known things to do (paint the fence, sand the floor, wax on/off): -// * centralize and move form styling into forms.scss - cms/static/sass/views/_textbooks.scss and cms/static/sass/views/_settings.scss -// * move dialogue styles into cms/static/sass/elements/_modal.scss -// * use the @include placeholder Bourbon mixin (http://bourbon.io/docs/#placeholder) for any placeholder styling +* centralize and move form styling into forms.scss - cms/static/sass/views/_textbooks.scss and cms/static/sass/views/_settings.scss +* move dialogue styles into cms/static/sass/elements/_modal.scss +* use the @include placeholder Bourbon mixin (http://bourbon.io/docs/#placeholder) for any placeholder styling + +*/ diff --git a/cms/static/sass/_variables.scss b/cms/static/sass/_variables.scss index 18ecdf7fef..093d3a957a 100644 --- a/cms/static/sass/_variables.scss +++ b/cms/static/sass/_variables.scss @@ -173,12 +173,14 @@ $tmg-f3: 0.125s; // ==================== // specific UI -$notification-height: ($baseline*10); +$ui-notification-height: ($baseline*10); +$ui-update-color: $blue-l4; // ==================== // inherited $baseFontColor: $gray-d2; +$lighter-base-font-color: rgb(100,100,100); $offBlack: #3c3c3c; $green: #108614; $lightGrey: #edf1f5; @@ -195,6 +197,17 @@ $lightBluishGrey: rgb(197, 207, 223); $lightBluishGrey2: rgb(213, 220, 228); $error-red: rgb(253, 87, 87); + +//carryover from LMS for xmodules +$sidebar-color: rgb(246, 246, 246); + // type $sans-serif: $f-sans-serif; $body-line-height: golden-ratio(.875em, 1); + +// carried over from LMS for xmodules +$action-primary-active-bg: #1AA1DE; // $m-blue +$very-light-text: #fff; + + + diff --git a/cms/static/sass/assets/_anims.scss b/cms/static/sass/assets/_anims.scss index 6e69f6c3df..8b66032e68 100644 --- a/cms/static/sass/assets/_anims.scss +++ b/cms/static/sass/assets/_anims.scss @@ -140,22 +140,22 @@ } 90% { - @include transform(translateY(-($notification-height))); + @include transform(translateY(-($ui-notification-height))); } 100% { - @include transform(translateY(-($notification-height*0.99))); + @include transform(translateY(-($ui-notification-height*0.99))); } } // notifications slide down @include keyframes(notificationSlideDown) { 0% { - @include transform(translateY(-($notification-height*0.99))); + @include transform(translateY(-($ui-notification-height*0.99))); } 10% { - @include transform(translateY(-($notification-height))); + @include transform(translateY(-($ui-notification-height))); } 100% { @@ -211,3 +211,39 @@ %anim-bounceOut { @include animation(bounceOut $tmg-f1 ease-in-out 1); } + + +// ==================== + + +// flash +@include keyframes(flash) { + 0%, 100% { + opacity: 1.0; + } + + 50% { + opacity: 0.0; + } +} + +// canned animation - use if you want out of the box/non-customized anim +%anim-flash { + @include animation(flash $tmg-f1 ease-in-out 1); +} + +// flash - double +@include keyframes(flashDouble) { +0%, 50%, 100% { + opacity: 1.0; +} + +25%, 75% { + opacity: 0.0; + } + } + +// canned animation - use if you want out of the box/non-customized anim +%anim-flashDouble { + @include animation(flashDouble $tmg-f1 ease-in-out 1); +} \ No newline at end of file diff --git a/cms/static/sass/elements/_controls.scss b/cms/static/sass/elements/_controls.scss index 3aa1d0ff8b..30a3a8561b 100644 --- a/cms/static/sass/elements/_controls.scss +++ b/cms/static/sass/elements/_controls.scss @@ -200,3 +200,83 @@ %view-live-button { @extend %t-action4; } + +// ==================== + +// UI: drag handles +.drag-handle { + + &:hover, &:focus { + cursor: move; + } +} + +// UI: elem is draggable +.is-draggable { + @include transition(border-color $tmg-f2 ease-in-out 0, box-shadow $tmg-f2 ease-in-out 0); + position: relative; + + .draggable-drop-indicator { + @extend %ui-depth3; + @include transition(opacity $tmg-f2 linear 0s); + @include size(100%, auto); + position: absolute; + border-top: 1px solid $blue-l1; + opacity: 0.0; + + *[class^="icon-caret"] { + @extend %t-icon5; + position: absolute; + top: -12px; + left: -($baseline/4); + color: $blue-s1; + } + } + + .draggable-drop-indicator-before { + top: -($baseline/2); + } + + .draggable-drop-indicator-after { + bottom: -($baseline/2); + } +} + +// UI: drag state - is dragging +.is-dragging { + @extend %ui-depth4; + left: -($baseline/4); + box-shadow: 0 1px 2px 0 $shadow-d1; + cursor: move; + opacity: 0.65; + border: 1px solid $gray-d3; + + // UI: condition - valid drop + &.valid-drop { + border-color: $blue-s1; + box-shadow: 0 1px 2px 0 $blue-t2; + } +} + +// UI: drag state - was dragging +.was-dragging { + @include transition(transform $tmg-f2 ease-in-out 0); +} + +// UI: drag target +.drop-target { + + &.drop-target-before { + + > .draggable-drop-indicator-before { + opacity: 1.0; + } + } + + &.drop-target-after { + + > .draggable-drop-indicator-after { + opacity: 1.0; + } + } +} diff --git a/cms/static/sass/elements/_system-feedback.scss b/cms/static/sass/elements/_system-feedback.scss index 188fd28251..6864d9ced4 100644 --- a/cms/static/sass/elements/_system-feedback.scss +++ b/cms/static/sass/elements/_system-feedback.scss @@ -712,7 +712,7 @@ // notification showing/hiding .wrapper-notification { - bottom: -($notification-height); + bottom: -($ui-notification-height); // varying animations &.is-shown { diff --git a/cms/static/sass/elements/_tender-widget.scss b/cms/static/sass/elements/_tender-widget.scss index 6b81044232..1b04cb19fd 100644 --- a/cms/static/sass/elements/_tender-widget.scss +++ b/cms/static/sass/elements/_tender-widget.scss @@ -1,6 +1,11 @@ // tender help/support widget // ==================== +// UI: hiding the default tender help "tag" element +#tender_toggler { + display: none; +} + #tender_frame, #tender_window { background-image: none !important; background: none; diff --git a/cms/static/sass/views/_account.scss b/cms/static/sass/views/_account.scss index 14fe0b8b05..ecf301ad6a 100644 --- a/cms/static/sass/views/_account.scss +++ b/cms/static/sass/views/_account.scss @@ -233,13 +233,6 @@ } } -} - -.signup { - -} - -.signin { #field-password { position: relative; diff --git a/cms/static/sass/views/_import.scss b/cms/static/sass/views/_import.scss index 680626279e..b77987263b 100644 --- a/cms/static/sass/views/_import.scss +++ b/cms/static/sass/views/_import.scss @@ -157,16 +157,8 @@ // CASE: has actions &.has-actions { - .status-detail { - width: flex-grid(5,9); - } - .list-actions { display: none; - width: flex-grid(3,9); - float: right; - margin-left: flex-gutter(); - text-align: right; .action-primary { @extend %btn-primary-blue; diff --git a/cms/static/sass/views/_outline.scss b/cms/static/sass/views/_outline.scss index 5ee9c5ab16..7a787bb5f0 100644 --- a/cms/static/sass/views/_outline.scss +++ b/cms/static/sass/views/_outline.scss @@ -137,394 +137,388 @@ .courseware-section { - position: relative; - background: #fff; - border-radius: 3px; - border: 1px solid $mediumGrey; - margin-top: 15px; - padding-bottom: 12px; - box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1); + @extend %ui-window; + @include transition(background $tmg-avg ease-in-out 0); + position: relative; + margin-top: ($baseline); + padding-bottom: ($baseline/2); - &:first-child { - margin-top: 0; - } + &.collapsed { + padding-bottom: 0; + } - &.collapsed { - padding-bottom: 0; - } + label { + float: left; + line-height: 29px; + } - label { - float: left; - line-height: 29px; - } + .datepair { + float: left; + margin-left: 10px; + } - .datepair { - float: left; - margin-left: 10px; - } + .section-published-date { + position: absolute; + top: 19px; + right: 80px; + padding: 4px 10px; + border-radius: 3px; + background: $lightGrey; + text-align: right; - .section-published-date { - position: absolute; - top: 19px; - right: 80px; - padding: 4px 10px; + .published-status { + @include font-size(12); + margin-right: 15px; + + strong { + font-weight: bold; + } + } + + .schedule-button { + @include blue-button; + } + + .edit-button { + @include blue-button; + } + + .schedule-button, + .edit-button { + @include font-size(11); + padding: 3px 15px 5px; + } + } + + .datepair .date, + .datepair .time { + @include font-size(13); + box-shadow: none; + padding-left: 0; + padding-right: 0; + border: none; + background: none; + font-weight: bold; + color: $blue; + cursor: pointer; + } + + .datepair .date { + width: 80px; + } + + .datepair .time { + width: 65px; + } + + &.collapsed .subsection-list, + .collapsed .subsection-list, + .collapsed > ol { + display: none !important; + } + + header { + min-height: 75px; + @include clearfix(); + + .item-details, .section-published-date { + + } + + .item-details { + display: inline-block; + padding: 20px 0 10px 0; + @include clearfix(); + + .section-name { + @include font-size(19); + float: left; + margin-right: 10px; + width: 350px; + font-weight: bold; + color: $blue; + } + + .section-name-span { + @include transition(color $tmg-f2 linear 0s); + cursor: pointer; + + &:hover { + color: $orange; + } + } + + .section-name-edit { + position: relative; + width: 400px; + background: $white; + + input { + @include font-size(16); + } + + .save-button { + @include blue-button; + padding: 7px 20px 7px; + margin-right: 5px; + } + + .cancel-button { + @include white-button; + padding: 7px 20px 7px; + } + } + + .section-published-date { + float: right; border-radius: 3px; background: $lightGrey; - text-align: right; .published-status { - @include font-size(12); - margin-right: 15px; + @include font-size(12); + margin-right: 15px; - strong { - font-weight: bold; - } + strong { + font-weight: bold; + } } .schedule-button { - @include blue-button; + @include blue-button; } .edit-button { - @include blue-button; + @include blue-button; } .schedule-button, .edit-button { - @include font-size(11); - padding: 3px 15px 5px; - } - } - - .datepair .date, - .datepair .time { - @include font-size(13); - box-shadow: none; - padding-left: 0; - padding-right: 0; - border: none; - background: none; - font-weight: bold; - color: $blue; - cursor: pointer; - } - - .datepair .date { - width: 80px; - } - - .datepair .time { - width: 65px; - } - - &.collapsed .subsection-list, - .collapsed .subsection-list, - .collapsed > ol { - display: none !important; - } - - header { - min-height: 75px; - @include clearfix(); - - .item-details, .section-published-date { - + @include font-size(11); + padding: 0 15px 2px 15px; + } } - .item-details { - display: inline-block; - padding: 20px 0 10px 0; - @include clearfix(); + .gradable-status { + position: absolute; + top: 20px; + right: 70px; + width: 145px; - .section-name { - @include font-size(19); - float: left; - margin-right: 10px; - width: 350px; - font-weight: bold; + .status-label { + @include font-size(12); + border-radius: 3px; + position: absolute; + top: 0; + right: 2px; + display: none; + width: 100px; + padding: 10px 35px 10px 10px; + background: $lightGrey; + color: $lightGrey; + text-align: right; + font-weight: bold; + line-height: 16px; + } + + .menu-toggle { + z-index: 10; + position: absolute; + top: 2px; + right: 5px; + padding: 5px; + color: $lightGrey; + + &:hover, &.is-active { color: $blue; } - .section-name-span { - @include transition(color $tmg-f2 linear 0s); - cursor: pointer; - - &:hover { - color: $orange; - } - } - - .section-name-edit { - position: relative; - width: 400px; - background: $white; - - input { - @include font-size(16); } - .save-button { - @include blue-button; - padding: 7px 20px 7px; - margin-right: 5px; - } - - .cancel-button { - @include white-button; - padding: 7px 20px 7px; - } - } - - .section-published-date { - float: right; - border-radius: 3px; - background: $lightGrey; - - .published-status { + .menu { @include font-size(12); - margin-right: 15px; - - strong { - font-weight: bold; - } - } - - .schedule-button { - @include blue-button; - } - - .edit-button { - @include blue-button; - } - - .schedule-button, - .edit-button { - @include font-size(11); - padding: 0 15px 2px 15px; - } - } - - .gradable-status { + @include transition(opacity $tmg-f2 linear 0s, display $tmg-f2 linear 0s); + border-radius: 4px; + box-shadow: 0 1px 2px rgba(0, 0, 0, .2); + z-index: 1; + display: none; + opacity: 0.0; position: absolute; - top: 20px; - right: 70px; - width: 145px; - - .status-label { - @include font-size(12); - border-radius: 3px; - position: absolute; - top: 0; - right: 2px; - display: none; - width: 100px; - padding: 10px 35px 10px 10px; - background: $lightGrey; - color: $lightGrey; - text-align: right; - font-weight: bold; - line-height: 16px; - } - - .menu-toggle { - z-index: 10; - position: absolute; - top: 2px; - right: 5px; - padding: 5px; - color: $lightGrey; - - &:hover, &.is-active { - color: $blue; - } - - } - - .menu { - @include font-size(12); - @include transition(opacity $tmg-f2 linear 0s, display $tmg-f2 linear 0s); - border-radius: 4px; - box-shadow: 0 1px 2px rgba(0, 0, 0, .2); - z-index: 1; - display: none; - opacity: 0.0; - position: absolute; - top: -1px; - left: 2px; - margin: 0; - padding: 8px 12px; - background: $white; - border: 1px solid $mediumGrey; + top: -1px; + left: 2px; + margin: 0; + padding: 8px 12px; + background: $white; + border: 1px solid $mediumGrey; - li { - width: 115px; - margin-bottom: 3px; - padding-bottom: 3px; - border-bottom: 1px solid $lightGrey; + li { + width: 115px; + margin-bottom: 3px; + padding-bottom: 3px; + border-bottom: 1px solid $lightGrey; - &:last-child { - margin-bottom: 0; - padding-bottom: 0; - border: none; + &:last-child { + margin-bottom: 0; + padding-bottom: 0; + border: none; - a { - color: $darkGrey; - } + a { + color: $darkGrey; } } - - a { - - &.is-selected { - font-weight: bold; - } - } - } - - // dropdown state - &.is-active { - - .menu { - z-index: 1000; - display: block; - opacity: 1.0; } + a { - .menu-toggle { - z-index: 10000; - } + &.is-selected { + font-weight: bold; + } + } } - // set state - &.is-set { + // dropdown state + &.is-active { - .menu-toggle { - color: $blue; - } - - .status-label { + .menu { + z-index: 1000; display: block; - color: $blue; + opacity: 1.0; } - } - float: left; - padding: 21px 0 0; + + .menu-toggle { + z-index: 10000; + } } + + // set state + &.is-set { + + .menu-toggle { + color: $blue; + } + + .status-label { + display: block; + color: $blue; + } + } + + float: left; + padding: 21px 0 0; } + } - .item-actions { - margin-top: 21px; - margin-right: 12px; + .item-actions { + margin-top: 21px; + margin-right: 12px; - .edit-button, - .delete-button { - margin-top: -3px; - } - } - - .expand-collapse-icon { - @include transition(none); - float: left; - margin: 25px 6px 16px 16px; - - &.expand { - background-position: 0 0; - } - - &.collapsed { - - } - } - - .drag-handle { - margin-left: 11px; - } - } - - h3 { - @include font-size(19); - font-weight: 700; - color: $blue; - } - - .section-name-span { - @include transition(color $tmg-f2 linear 0s); - cursor: pointer; - - &:hover { - color: $orange; - } - } - - .section-name-form { - margin-bottom: 15px; - } - - .section-name-edit { - input { - @include font-size(16); - } - - .save-button { - @include blue-button; - padding: 7px 20px 7px; - margin-right: 5px; - } - - .cancel-button { - @include white-button; - padding: 7px 20px 7px; - } - } - - h4 { - @include font-size(12); - color: #878e9d; - - strong { - font-weight: bold; - } - } - - .list-header { - @include linear-gradient(top, transparent, rgba(0, 0, 0, .1)); - background-color: #ced2db; - border-radius: 3px 3px 0 0; - } - - .subsection-list { - margin: 0 12px; - - > ol { - @include tree-view; - border-top-width: 0; - } - } - - &.new-section { - - header { - @include clearfix(); - height: auto; - } - - .expand-collapse-icon { - visibility: hidden; - } - - .item-details { - padding: 25px 0 0 0; - - .section-name { - float: none; - width: 100%; + .edit-button, + .delete-button { + margin-top: -3px; } + } + + .expand-collapse-icon { + @include transition(none); + float: left; + margin: 25px 6px 16px 16px; + + &.expand { + background-position: 0 0; + } + + &.collapsed { + + } + } + + .drag-handle { + margin-left: 11px; + } + } + + h3 { + @include font-size(19); + font-weight: 700; + color: $blue; + } + + .section-name-span { + @include transition(color $tmg-f2 linear 0s); + cursor: pointer; + + &:hover { + color: $orange; + } + } + + .section-name-form { + margin-bottom: 15px; + } + + .section-name-edit { + input { + @include font-size(16); + } + + .save-button { + @include blue-button; + padding: 7px 20px 7px; + margin-right: 5px; + } + + .cancel-button { + @include white-button; + padding: 7px 20px 7px; + } + } + + h4 { + @include font-size(12); + color: #878e9d; + + strong { + font-weight: bold; + } + } + + .list-header { + @include linear-gradient(top, transparent, rgba(0, 0, 0, .1)); + background-color: #ced2db; + border-radius: 3px 3px 0 0; + } + + .subsection-list { + margin: 0 12px; + + > ol { + @include tree-view; + border-top-width: 0; + } + } + + &.new-section { + + header { + @include clearfix(); + height: auto; + } + + .expand-collapse-icon { + visibility: hidden; + } + + .item-details { + padding: 25px 0 0 0; + + .section-name { + float: none; + width: 100%; } - } + } + } } .toggle-button-sections { @@ -675,35 +669,75 @@ color: $darkGrey; } - // sort/drag and drop - .ui-droppable { - @include transition (padding 0.5s ease-in-out 0s); - min-height: 20px; - padding: 0; + // UI: DnD - specific elems/cases - section + .courseware-section { - &.dropover { - padding: 15px 0; - } + .draggable-drop-indicator-before { + top: -($baseline/2); + } + + .draggable-drop-indicator-after { + bottom: -13px; + } + + // CASE: DnD - empty subsection with unit dropping + .drop-target-prepend .draggable-drop-indicator-initial { + opacity: 1.0; + } + + // STATE: was dropped + &.was-dropped { + background-color: $ui-update-color; + } } - .ui-draggable-dragging { - box-shadow: 0 1px 2px rgba(0, 0, 0, .3); - border: 1px solid $darkGrey; - opacity : 0.2; - &:hover { - opacity : 1.0; - .section-item { - background: $yellow !important; - } - } + // UI: DnD - specific elems/cases - subsection + .courseware-subsection { - // hiding unit button - temporary fix until this semantically corrected - .new-unit-item { - display: none; + .draggable-drop-indicator-before { + top: 0; + } + + .draggable-drop-indicator-after { + bottom: 0; + } + + // CASE: DnD - empty subsection with unit dropping + .drop-target-prepend .draggable-drop-indicator-initial { + opacity: 1.0; + } + + // STATE: was dropped + &.was-dropped { + + > .section-item { + background-color: $ui-update-color !important; // nasty, but needed for specificity } + } } - ol.ui-droppable .branch:first-child .section-item { - border-top: none; + // UI: DnD - specific elems/cases - unit + .courseware-unit { + + .draggable-drop-indicator-before { + top: 0; + } + + .draggable-drop-indicator-after { + bottom: 0; + } + + // STATE: was dropped + &.was-dropped { + + > .section-item { + background-color: $ui-update-color !important; // nasty, but needed for specificity + } + } + } + + // UI: DnD - specific elems/cases - empty parents splint + .ui-splint-indicator { + position: relative; } } diff --git a/cms/static/sass/views/_subsection.scss b/cms/static/sass/views/_subsection.scss index 97353d919b..d6da939115 100644 --- a/cms/static/sass/views/_subsection.scss +++ b/cms/static/sass/views/_subsection.scss @@ -400,4 +400,21 @@ } } } + + // UI: DnD - specific elems/cases - units + .courseware-unit { + + .draggable-drop-indicator-before { + top: 0; + } + + .draggable-drop-indicator-after { + bottom: 0; + } + } + + // UI: DnD - specific elems/cases - empty parents initial drop indicator + .draggable-drop-indicator-initial { + display: none; + } } diff --git a/cms/static/sass/views/_unit.scss b/cms/static/sass/views/_unit.scss index 9a4eb16e52..50685123a2 100644 --- a/cms/static/sass/views/_unit.scss +++ b/cms/static/sass/views/_unit.scss @@ -355,6 +355,20 @@ body.course.unit,.view-unit { } } + + .wrapper-alert-error { + margin-top: ($baseline*1.25); + box-shadow: none; + border-top: 5px solid $red-l1; + + .copy, + .title { + color: $white; + } + + } + + } } @@ -415,6 +429,19 @@ body.course.unit,.view-unit { margin-left: 0; } } + + // UI: DnD - specific elems/cases - unit + .courseware-unit { + + // STATE: was dropped + &.was-dropped { + + > .section-item { + background-color: $ui-update-color !important; // nasty, but needed for specificity + } + } + } + // ==================== // Component Editing diff --git a/cms/templates/base.html b/cms/templates/base.html index a27cac7760..152bb40918 100644 --- a/cms/templates/base.html +++ b/cms/templates/base.html @@ -33,6 +33,7 @@ window.baseUrl = "${settings.STATIC_URL}"; var require = { baseUrl: baseUrl, + waitSeconds: 60, paths: { "domReady": "js/vendor/domReady", "gettext": "/i18n", @@ -60,12 +61,18 @@ var require = { "underscore.string": "js/vendor/underscore.string.min", "backbone": "js/vendor/backbone-min", "backbone.associations": "js/vendor/backbone-associations-min", - "youtube": "js/load_youtube", "tinymce": "js/vendor/tiny_mce/tiny_mce", "jquery.tinymce": "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/xmodule", - "utility": "js/src/utility" + "utility": "js/src/utility", + "draggabilly": "js/vendor/draggabilly.pkgd", + + // externally hosted files + "mathjax": "//edx-static.s3.amazonaws.com/mathjax-MathJax-727332c/MathJax.js?config=TeX-MML-AM_HTMLorMML-full", + // youtube URL does not end in ".js". We add "?noext" to the path so + // that require.js adds the ".js" to the query component of the URL, + // and leaves the path component intact. + "youtube": "//www.youtube.com/player_api?noext" }, shim: { "gettext": { @@ -136,6 +143,9 @@ var require = { deps: ["backbone"], exports: "Backbone.Associations" }, + "youtube": { + exports: "YT" + }, "codemirror": { exports: "CodeMirror" }, @@ -147,16 +157,27 @@ var require = { }, "mathjax": { exports: "MathJax" + }, + + "coffee/src/main": { + deps: ["coffee/src/ajax_prefix"] + }, + "coffee/src/logger": { + exports: "Logger", + deps: ["coffee/src/ajax_prefix"] } }, - // load these automatically - deps: ["js/base", "coffee/src/main", "datepair"] - // we need "datepair" because it dynamically modifies the page when it is loaded -- yuck! + // load jquery and gettext automatically + deps: ["jquery", "gettext"], + callback: function() { + // load other scripts on every page, after jquery loads + require(["js/base", "coffee/src/main", "coffee/src/logger", "datepair"]); + // we need "datepair" because it dynamically modifies the page + // when it is loaded -- yuck! + } }; - - ## js templates <%include file="widgets/sock.html" /> % endif diff --git a/cms/templates/component.html b/cms/templates/component.html index 7496e85b18..07ab92c592 100644 --- a/cms/templates/component.html +++ b/cms/templates/component.html @@ -31,6 +31,6 @@ ${_("Edit")} ${_("Delete")} - + ${preview} diff --git a/cms/templates/edit_subsection.html b/cms/templates/edit_subsection.html index 364411b01c..8a4d61806c 100644 --- a/cms/templates/edit_subsection.html +++ b/cms/templates/edit_subsection.html @@ -21,9 +21,11 @@ -
      - - ${units.enum_units(subsection, subsection_units=subsection_units)} +
      +
      + + ${units.enum_units(subsection, subsection_units=subsection_units)} +
  • @@ -112,9 +114,8 @@ require(["domReady!", "jquery", "js/models/location", "js/views/overview_assignm // I believe that current (New Section/New Subsection) cause full page reloads which means these aren't needed globally // but we really should change that behavior. if (!window.graderTypes) { - window.graderTypes = new CourseGraderCollection(); + window.graderTypes = new CourseGraderCollection(${course_graders|n}, {parse:true}); window.graderTypes.course_location = new Location('${parent_location}'); - window.graderTypes.reset(${course_graders|n}); } $(".gradable-status").each(function(index, ele) { diff --git a/cms/templates/html_error.html b/cms/templates/html_error.html new file mode 100644 index 0000000000..65bf870029 --- /dev/null +++ b/cms/templates/html_error.html @@ -0,0 +1,26 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> + +<%block name="content"> +
    +
    + +
    +

    + + ${_("We're having trouble rendering your component")} +

    + +

    ${_("Students will not be able to access this component. Re-edit your component to fix the error.")}

    + + % if message: +

    + ${_("Error:")} + ${message | h} +

    + % endif + + +
    +
    + diff --git a/cms/templates/import.html b/cms/templates/import.html index 7daf4f78d4..82876148e8 100644 --- a/cms/templates/import.html +++ b/cms/templates/import.html @@ -111,13 +111,13 @@

    ${_("Success")}

    ${_("Your imported content has now been integrated into this course")}

    -
    - + +
    diff --git a/cms/templates/index.html b/cms/templates/index.html index df8459e448..82183c10b7 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -7,29 +7,26 @@ <%block name="jsextra"> diff --git a/cms/templates/overview.html b/cms/templates/overview.html index b3888d8706..5d75e3922a 100644 --- a/cms/templates/overview.html +++ b/cms/templates/overview.html @@ -25,9 +25,8 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v // I believe that current (New Section/New Subsection) cause full page reloads which means these aren't needed globally // but we really should change that behavior. if (!window.graderTypes) { - window.graderTypes = new CourseGraderCollection(); + window.graderTypes = new CourseGraderCollection(${course_graders|n}, {parse:true}); window.graderTypes.course_location = new Location('${parent_location}'); - window.graderTypes.reset(${course_graders|n}); } $(".gradable-status").each(function(index, ele) { @@ -82,7 +81,7 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v
    - +
    @@ -138,74 +137,90 @@ require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/v
    -
    - % for section in sections: -
    -
    - -
    -

    - -
    +
    +
    + % for section in sections: +
    -
    - - -
    -
    -
    - -
      - % for subsection in section.get_children(): - - % endfor -
    -
    -
    - % endfor -
    +
    + +
    + + +
    + +
    + +
      + % for subsection in section.get_children(): + + % endfor +
    1. + <%include file="widgets/_ui-dnd-indicator-initial.html" /> +
    2. +
    +
    + + <%include file="widgets/_ui-dnd-indicator-after.html" /> + + % endfor +
    +
    diff --git a/cms/templates/settings.html b/cms/templates/settings.html index 961b7d3d92..f3eec1e195 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -17,6 +17,10 @@ diff --git a/cms/templates/settings_discussions_faculty.html b/cms/templates/settings_discussions_faculty.html deleted file mode 100644 index a3c57860b8..0000000000 --- a/cms/templates/settings_discussions_faculty.html +++ /dev/null @@ -1,429 +0,0 @@ -<%! from django.utils.translation import ugettext as _ %> - -<%inherit file="base.html" /> -<%block name="title">${_("Schedule and details")} -<%block name="bodyclass">is-signedin course view-settings - - -<%namespace name='static' file='static_content.html'/> -<%! -from contentstore import utils -%> - - -<%block name="jsextra"> - - - - - - -<%block name="content"> - -
    -
    -

    ${_("Settings")}

    -
    -
    - -
    -

    ${_("Faculty")}

    - -
    -
    -

    ${_("Faculty Members")}

    - ${_("Individuals instructing and helping with this course")} -
    - -
    -
    -
      -
    • -
      - -
      - -
      -
      - -
      - -
      - -
      -
      - -
      - - -
      - -
      - -
      - - ${_("A brief description of your education, experience, and expertise")} -
      -
      - - ${_("Delete Faculty Member")} -
    • - -
    • -
      - -
      - -
      -
      - -
      - -
      - -
      -
      - -
      - -
      -
      - - ${_("Upload Faculty Photo")} - - ${_("Max size: 30KB")} -
      -
      -
      - -
      - -
      -
      - - ${_("A brief description of your education, experience, and expertise")} -
      -
      -
      -
    • -
    - - - ${_("New Faculty Member")} - -
    -
    -
    - -
    - -
    -

    ${_("Problems")}

    - -
    -
    -

    ${_("General Settings")}

    - ${_("Course-wide settings for all problems")} -
    - -
    -

    ${_("Problem Randomization:")}

    - -
    -
    - - -
    - - ${_("randomize all problems")} -
    -
    - -
    - - -
    - - ${_("do not randomize problems")} -
    -
    - -
    - - -
    - - ${_("randomize problems per student")} -
    -
    -
    -
    - -
    -

    ${_("Show Answers:")}

    - -
    -
    - - -
    - - ${_("Answers will be shown after the number of attempts has been met")} -
    -
    - -
    - - -
    - - ${_("Answers will never be shown, regardless of attempts")} -
    -
    -
    -
    - -
    - - -
    -
    - - ${_('Students will this have this number of chances to answer a problem. To set infinite atttempts, use "0"')} -
    -
    -
    -
    - -
    -
    -

    [${_("Assignment Type Name")}]

    -
    - -
    -

    ${_("Problem Randomization:")}

    - -
    -
    - - -
    - - ${_("randomize all problems")} -
    -
    - -
    - - -
    - - ${_("do not randomize problems")} -
    -
    - -
    - - -
    - - ${_("randomize problems per student")} -
    -
    -
    -
    - -
    -

    ${_("Show Answers:")}

    - -
    -
    - - -
    - - ${_("Answers will be shown after the number of attempts has been met")} -
    -
    - -
    - - -
    - - ${_("Answers will never be shown, regardless of attempts")} -
    -
    -
    -
    - -
    - - -
    -
    - - ${_('Students will this have this number of chances to answer a problem. To set infinite atttempts, use "0"')} -
    -
    -
    -
    -
    - -
    -

    ${_("Discussions")}

    - -
    -
    -

    ${_("General Settings")}

    - ${_("Course-wide settings for online discussion")} -
    - -
    -

    ${_("Anonymous Discussions:")}

    - -
    -
    - - -
    - - ${_("Students and faculty will be able to post anonymously")} -
    -
    - -
    - - -
    - - ${_("Posting anonymously is not allowed. Any previous anonymous posts will be reverted to non-anonymous")} -
    -
    -
    -
    - -
    -

    ${_("Anonymous Discussions:")}

    - -
    -
    - - -
    - - ${_("Students and faculty will be able to post anonymously")} -
    -
    - -
    - - -
    - - ${_("This option is disabled since there are previous discussions that are anonymous.")} -
    -
    -
    -
    - -
    -

    ${_("Discussion Categories")}

    - -
    - - - - ${_("New Discussion Category")} - -
    -
    -
    -
    -
    -
    -
    -
    -
    - diff --git a/cms/templates/static-pages.html b/cms/templates/static-pages.html deleted file mode 100644 index 4086f55e09..0000000000 --- a/cms/templates/static-pages.html +++ /dev/null @@ -1,42 +0,0 @@ -<%! from django.utils.translation import ugettext as _ %> -<%inherit file="base.html" /> -<%! from django.core.urlresolvers import reverse %> -<%block name="title">${_("Static Pages")} -<%block name="bodyclass">view-static-pages - -<%block name="content"> -
    -
    -

    Static Pages

    -
    - -
    - -
    -
    - diff --git a/cms/templates/widgets/_ui-dnd-indicator-after.html b/cms/templates/widgets/_ui-dnd-indicator-after.html new file mode 100644 index 0000000000..256e7926ff --- /dev/null +++ b/cms/templates/widgets/_ui-dnd-indicator-after.html @@ -0,0 +1 @@ + diff --git a/cms/templates/widgets/_ui-dnd-indicator-before.html b/cms/templates/widgets/_ui-dnd-indicator-before.html new file mode 100644 index 0000000000..e76f19c043 --- /dev/null +++ b/cms/templates/widgets/_ui-dnd-indicator-before.html @@ -0,0 +1 @@ + diff --git a/cms/templates/widgets/_ui-dnd-indicator-initial.html b/cms/templates/widgets/_ui-dnd-indicator-initial.html new file mode 100644 index 0000000000..5ab8fddfe2 --- /dev/null +++ b/cms/templates/widgets/_ui-dnd-indicator-initial.html @@ -0,0 +1 @@ + diff --git a/cms/templates/widgets/source-edit.html b/cms/templates/widgets/source-edit.html index 6797fd224f..2475d03873 100644 --- a/cms/templates/widgets/source-edit.html +++ b/cms/templates/widgets/source-edit.html @@ -28,7 +28,7 @@ % endif +${fragment.foot_html()} + % if timer_expiration_duration: @@ -185,7 +188,7 @@ % endif
    - ${content} + ${fragment.body_html()}
    @@ -205,24 +208,23 @@ % if course.show_calculator:
    - ${_("Calculator")} - +
    - +
    - ${_("Hints")} -
    -
    ${_("Suffixes:")}
    -
    %kMGTcmunp
    -
    ${_("Operations:")}
    -
    ^ * / + - ()
    -
    ${_("Functions:")}
    -
    sin, cos, tan, sqrt, log10, log2, ln, arccos, arcsin, arctan, abs
    -
    ${_("Constants")}
    -
    e, pi
    + +
    +
    ${_("Suffixes:")}
    +
    %kMGTcmunp
    +
    ${_("Operations:")}
    +
    ^ * / + - ()
    +
    ${_("Functions:")}
    +
    sin, cos, tan, sqrt, log10, log2, ln, arccos, arcsin, arctan, abs
    +
    ${_("Constants")}
    +
    e, pi