diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d794f638ea..380d09fe23 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,8 @@ the top. Include a label indicating the component affected. LMS: Instructors can request and see content of previous bulk emails sent in the instructor dashboard. +Studio: Backbone version of the course outline page. STUD-1726. + Studio: New advanced setting "invitation_only" for courses. This setting overrides the enrollment start/end dates if set. LMS-2670 diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index c22eb92083..25269906e8 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -57,6 +57,26 @@ def i_have_opened_a_new_course(_step): open_new_course() +@step('I have populated a new course in Studio$') +def i_have_populated_a_new_course(_step): + 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') + world.wait_for_js_to_load() + + @step('(I select|s?he selects) the new course') def select_new_course(_step, whom): course_link_css = 'a.course-link' @@ -182,24 +202,9 @@ def create_a_course(): assert_true(world.is_css_present(course_title_css)) -def add_section(name='My Section'): - link_css = 'a.new-courseware-section-button' - world.css_click(link_css) - name_css = 'input.new-section-name' - save_css = 'input.new-section-name-save' - world.css_fill(name_css, name) - world.css_click(save_css) - span_css = 'span.section-name-span' - assert_true(world.is_css_present(span_css)) - - -def add_subsection(name='Subsection One'): - css = 'a.new-subsection-item' - world.css_click(css) - name_css = 'input.new-subsection-name-input' - save_css = 'input.new-subsection-name-save' - world.css_fill(name_css, name) - world.css_click(save_css) +def add_section(): + world.css_click('.course-outline .add-button') + assert_true(world.is_css_present('.outline-item-section .xblock-field-value')) def set_date_and_time(date_css, desired_date, time_css, desired_time, key=None): @@ -230,36 +235,13 @@ def i_enabled_the_advanced_module(step, module): @world.absorb -def create_course_with_unit(): +def create_unit_from_course_outline(): """ - 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 + Expands the section and clicks 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') - - world.wait_for_js_to_load() css_selectors = [ - 'div.section-item a.expand-collapse', 'a.new-unit-item' + '.outline-item-subsection .expand-collapse', '.outline-item-subsection .add-button' ] for selector in css_selectors: world.css_click(selector) @@ -273,7 +255,8 @@ def create_course_with_unit(): @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.given('I have populated a new course in Studio') + create_unit_from_course_outline() @step('the save notification button is disabled') diff --git a/cms/djangoapps/contentstore/features/course-overview.feature b/cms/djangoapps/contentstore/features/course-outline.feature similarity index 69% rename from cms/djangoapps/contentstore/features/course-overview.feature rename to cms/djangoapps/contentstore/features/course-outline.feature index a10237de5d..67efc84588 100644 --- a/cms/djangoapps/contentstore/features/course-overview.feature +++ b/cms/djangoapps/contentstore/features/course-outline.feature @@ -1,43 +1,43 @@ @shard_1 -Feature: CMS.Course Overview +Feature: CMS.Course Outline In order to quickly view the details of a course's section and set release dates and grading As a course author - I want to use the course overview page + I want to use the course outline page - Scenario: The default layout for the overview page is to show sections in expanded view + Scenario: The default layout for the outline page is to show sections in expanded view Given I have a course with multiple sections - When I navigate to the course overview page + When I navigate to the course outline page Then I see the "Collapse All Sections" link And all sections are expanded Scenario: Expand /collapse for a course with no sections Given I have a course with no sections - When I navigate to the course overview page + When I navigate to the course outline page Then I do not see the "Collapse All Sections" link Scenario: Collapse link appears after creating first section of a course Given I have a course with no sections - When I navigate to the course overview page + When I navigate to the course outline page And I add a section Then I see the "Collapse All Sections" link And all sections are expanded - Scenario: Collapse link is not removed after last section of a course is deleted + Scenario: Collapse link is removed after last section of a course is deleted Given I have a course with 1 section - And I navigate to the course overview page - When I will confirm all alerts + And I navigate to the course outline page And I press the "section" delete icon - Then I see the "Collapse All Sections" link + When I will confirm all alerts + Then I do not see the "Collapse All Sections" link Scenario: Collapsing all sections when all sections are expanded - Given I navigate to the courseware page of a course with multiple sections + Given I navigate to the outline page of a course with multiple sections And all sections are expanded When I click the "Collapse All Sections" link Then I see the "Expand All Sections" link And all sections are collapsed Scenario: Collapsing all sections when 1 or more sections are already collapsed - Given I navigate to the courseware page of a course with multiple sections + Given I navigate to the outline page of a course with multiple sections And all sections are expanded When I collapse the first section And I click the "Collapse All Sections" link @@ -45,14 +45,14 @@ Feature: CMS.Course Overview And all sections are collapsed Scenario: Expanding all sections when all sections are collapsed - Given I navigate to the courseware page of a course with multiple sections + Given I navigate to the outline page of a course with multiple sections And I click the "Collapse All Sections" link When I click the "Expand All Sections" link Then I see the "Collapse All Sections" link And all sections are expanded Scenario: Expanding all sections when 1 or more sections are already expanded - Given I navigate to the courseware page of a course with multiple sections + Given I navigate to the outline page of a course with multiple sections And I click the "Collapse All Sections" link When I expand the first section And I click the "Expand All Sections" link diff --git a/cms/djangoapps/contentstore/features/course-overview.py b/cms/djangoapps/contentstore/features/course-outline.py similarity index 53% rename from cms/djangoapps/contentstore/features/course-overview.py rename to cms/djangoapps/contentstore/features/course-outline.py index 6890e39491..f3203880aa 100644 --- a/cms/djangoapps/contentstore/features/course-overview.py +++ b/cms/djangoapps/contentstore/features/course-outline.py @@ -48,75 +48,83 @@ def have_a_course_with_two_sections(step): display_name='Subsection Beta',) -@step(u'I navigate to the course overview page$') -def navigate_to_the_course_overview_page(step): +@step(u'I navigate to the course outline page$') +def navigate_to_the_course_outline_page(step): create_studio_user(is_staff=True) log_into_studio() course_locator = 'a.course-link' world.css_click(course_locator) -@step(u'I navigate to the courseware page of a course with multiple sections') -def nav_to_the_courseware_page_of_a_course_with_multiple_sections(step): +@step(u'I navigate to the outline page of a course with multiple sections') +def nav_to_the_outline_page_of_a_course_with_multiple_sections(step): step.given('I have a course with multiple sections') - step.given('I navigate to the course overview page') + step.given('I navigate to the course outline page') @step(u'I add a section') def i_add_a_section(step): - add_section(name='My New Section That I Just Added') + add_section() -@step(u'I click the "([^"]*)" link$') -def i_click_the_text_span(step, text): - span_locator = '.toggle-button-sections span' - assert_true(world.browser.is_element_present_by_css(span_locator)) - # first make sure that the expand/collapse text is the one you expected - assert_true(world.css_has_value(span_locator, text)) - world.css_click(span_locator) +@step(u'I press the "section" delete icon') +def i_press_the_section_delete_icon(step): + delete_locator = 'section .outline-item-section > .wrapper-xblock-header a.delete-button' + world.css_click(delete_locator) -@step(u'I collapse the first section$') -def i_collapse_a_section(step): - collapse_locator = 'section.courseware-section a.collapse' - world.css_click(collapse_locator) +@step(u'I will confirm all alerts') +def i_confirm_all_alerts(step): + confirm_locator = '.prompt .nav-actions a.action-primary' + world.css_click(confirm_locator) -@step(u'I expand the first section$') -def i_expand_a_section(step): - expand_locator = 'section.courseware-section a.expand' - world.css_click(expand_locator) - - -@step(u'I see the "([^"]*)" link$') -def i_see_the_span_with_text(step, text): - span_locator = '.toggle-button-sections span' - assert_true(world.css_has_value(span_locator, text)) +@step(u'I see the "([^"]*) All Sections" link$') +def i_see_the_collapse_expand_all_span(step, text): + if text == "Collapse": + span_locator = '.toggle-button-expand-collapse .collapse-all .label' + elif text == "Expand": + span_locator = '.toggle-button-expand-collapse .expand-all .label' assert_true(world.css_visible(span_locator)) -@step(u'I do not see the "([^"]*)" link$') -def i_do_not_see_the_span_with_text(step, text): - # Note that the span will exist on the page but not be visible - span_locator = '.toggle-button-sections span' - assert_true(world.is_css_present(span_locator)) +@step(u'I do not see the "([^"]*) All Sections" link$') +def i_do_not_see_the_collapse_expand_all_span(step, text): + if text == "Collapse": + span_locator = '.toggle-button-expand-collapse .collapse-all .label' + elif text == "Expand": + span_locator = '.toggle-button-expand-collapse .expand-all .label' assert_false(world.css_visible(span_locator)) -@step(u'all sections are expanded$') -def all_sections_are_expanded(step): +@step(u'I click the "([^"]*) All Sections" link$') +def i_click_the_collapse_expand_all_span(step, text): + if text == "Collapse": + span_locator = '.toggle-button-expand-collapse .collapse-all .label' + elif text == "Expand": + span_locator = '.toggle-button-expand-collapse .expand-all .label' + assert_true(world.browser.is_element_present_by_css(span_locator)) + world.css_click(span_locator) + + +@step(u'I ([^"]*) the first section$') +def i_collapse_expand_a_section(step, text): + if text == "collapse": + locator = 'section .outline-item-section .ui-toggle-expansion' + elif text == "expand": + locator = 'section .outline-item-section .ui-toggle-expansion' + world.css_click(locator) + + +@step(u'all sections are ([^"]*)$') +def all_sections_are_collapsed_or_expanded(step, text): subsection_locator = 'div.subsection-list' subsections = world.css_find(subsection_locator) for index in range(len(subsections)): - assert_true(world.css_visible(subsection_locator, index=index)) - - -@step(u'all sections are collapsed$') -def all_sections_are_collapsed(step): - subsection_locator = 'div.subsection-list' - subsections = world.css_find(subsection_locator) - for index in range(len(subsections)): - assert_false(world.css_visible(subsection_locator, index=index)) + if text == "collapsed": + assert_false(world.css_visible(subsection_locator, index=index)) + elif text == "expanded": + assert_true(world.css_visible(subsection_locator, index=index)) @step(u"I change an assignment's grading status") diff --git a/cms/djangoapps/contentstore/features/course-updates.py b/cms/djangoapps/contentstore/features/course-updates.py index 4793948b19..69670013cb 100644 --- a/cms/djangoapps/contentstore/features/course-updates.py +++ b/cms/djangoapps/contentstore/features/course-updates.py @@ -130,3 +130,9 @@ def verify_text_in_editor_and_update(button_css, before, after): text = get_codemirror_value() assert_in(before, text) change_text(after) + + +@step('I see a "(saving|deleting)" notification') +def i_see_a_mini_notification(_step, _type): + saving_css = '.wrapper-notification-mini' + assert world.is_css_present(saving_css) diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py index a0502fe92a..6425d1c4c5 100644 --- a/cms/djangoapps/contentstore/features/courses.py +++ b/cms/djangoapps/contentstore/features/courses.py @@ -66,5 +66,5 @@ def i_am_on_tab(step, tab_name): @step('I see a link for adding a new section$') def i_see_new_section_link(step): - link_css = 'a.new-courseware-section-button' + link_css = '.course-outline .add-button' assert world.css_has_text(link_css, 'New Section') diff --git a/cms/djangoapps/contentstore/features/discussion-editor.py b/cms/djangoapps/contentstore/features/discussion-editor.py index d16279be4d..bca91f1a74 100644 --- a/cms/djangoapps/contentstore/features/discussion-editor.py +++ b/cms/djangoapps/contentstore/features/discussion-editor.py @@ -6,7 +6,7 @@ from lettuce import world, step @step('I have created a Discussion Tag$') def i_created_discussion_tag(step): - world.create_course_with_unit() + step.given('I am in Studio editing a new unit') world.create_component_instance( step=step, category='discussion', diff --git a/cms/djangoapps/contentstore/features/grading.feature b/cms/djangoapps/contentstore/features/grading.feature index 6c357a171e..419309d12d 100644 --- a/cms/djangoapps/contentstore/features/grading.feature +++ b/cms/djangoapps/contentstore/features/grading.feature @@ -32,8 +32,7 @@ Feature: CMS.Course Grading Then I see that the grade range has changed Scenario: Users can modify Assignment types - Given I have opened a new course in Studio - And I have populated the course + Given I have populated a new course in Studio And I am viewing the grading settings When I change assignment type "Homework" to "New Type" And I press the "Save" notification button @@ -42,8 +41,7 @@ Feature: CMS.Course Grading And I do not see the assignment name "Homework" Scenario: Users can delete Assignment types - Given I have opened a new course in Studio - And I have populated the course + Given I have populated a new course in Studio And I am viewing the grading settings When I delete the assignment type "Homework" And I press the "Save" notification button @@ -51,8 +49,7 @@ Feature: CMS.Course Grading Then I do not see the assignment name "Homework" Scenario: Users can add Assignment types - Given I have opened a new course in Studio - And I have populated the course + Given I have populated a new course in Studio And I am viewing the grading settings When I add a new assignment type "New Type" And I press the "Save" notification button @@ -71,31 +68,27 @@ Feature: CMS.Course Grading 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 + Given I have populated a new course in Studio And I am viewing the grading settings When I change assignment type "Homework" to "New Type" Then I do not see the changes persisted on refresh Scenario: Settings are reset on cancel - Given I have opened a new course in Studio - And I have populated the course + Given I have populated a new course in Studio And I am viewing the grading settings When I change assignment type "Homework" to "New Type" And I press the "Cancel" notification button Then I see the assignment type "Homework" Scenario: Confirmation is shown on save - Given I have opened a new course in Studio - And I have populated the course + Given I have populated a new course in Studio And I am viewing the grading settings When I change assignment type "Homework" to "New Type" And I press the "Save" notification button Then I see a confirmation that my changes have been saved Scenario: User cannot save invalid settings - Given I have opened a new course in Studio - And I have populated the course + Given I have populated a new course in Studio And I am viewing the grading settings When I change assignment type "Homework" to "" Then the save notification button is disabled @@ -104,8 +97,7 @@ Feature: CMS.Course Grading @skip_internetexplorer @skip_safari Scenario: User can edit grading range names - Given I have opened a new course in Studio - And I have populated the course + Given I have populated a new course in Studio And I am viewing the grading settings When I change the highest grade range to "Good" And I press the "Save" notification button @@ -113,14 +105,12 @@ Feature: CMS.Course Grading Then I see the highest grade range is "Good" Scenario: User cannot edit failing grade range name - Given I have opened a new course in Studio - And I have populated the course + Given I have populated a new course in Studio And I am viewing the grading settings Then I cannot edit the "Fail" grade range Scenario: User can set a grace period greater than one day - Given I have opened a new course in Studio - And I have populated the course + Given I have populated a new course in Studio And I am viewing the grading settings When I change the grace period to "48:00" And I press the "Save" notification button @@ -128,8 +118,7 @@ Feature: CMS.Course Grading Then I see the grace period is "48:00" Scenario: Grace periods of more than 59 minutes are wrapped to the correct time - Given I have opened a new course in Studio - And I have populated the course + Given I have populated a new course in Studio And I am viewing the grading settings When I change the grace period to "01:99" And I press the "Save" notification button diff --git a/cms/djangoapps/contentstore/features/grading.py b/cms/djangoapps/contentstore/features/grading.py index 862d2f2462..431b6d06b5 100644 --- a/cms/djangoapps/contentstore/features/grading.py +++ b/cms/djangoapps/contentstore/features/grading.py @@ -82,19 +82,21 @@ def main_course_page(step): @step(u'I do( not)? see the assignment name "([^"]*)"$') def see_assignment_name(step, do_not, name): - assignment_menu_css = 'ul.menu > li > a' - # First assert that it is there, make take a bit to redraw - assert_true( - world.css_find(assignment_menu_css), - msg="Could not find assignment menu" - ) - - assignment_menu = world.css_find(assignment_menu_css) - allnames = [item.html for item in assignment_menu] - if do_not: - assert_not_in(name, allnames) - else: - assert_in(name, allnames) + # TODO: rewrite this once grading has been added back to the course outline + pass + # assignment_menu_css = 'ul.menu > li > a' + # # First assert that it is there, make take a bit to redraw + # assert_true( + # world.css_find(assignment_menu_css), + # msg="Could not find assignment menu" + # ) + # + # assignment_menu = world.css_find(assignment_menu_css) + # allnames = [item.html for item in assignment_menu] + # if do_not: + # assert_not_in(name, allnames) + # else: + # assert_in(name, allnames) @step(u'I delete the assignment type "([^"]*)"$') @@ -128,12 +130,6 @@ def verify_weight(step, weight): 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') - step.given('I have added a new subsection') - - @step(u'I do not see the changes persisted on refresh$') def changes_not_persisted(step): reload_the_page(step) diff --git a/cms/djangoapps/contentstore/features/help.feature b/cms/djangoapps/contentstore/features/help.feature index ef6bfe33cc..4876396cfd 100644 --- a/cms/djangoapps/contentstore/features/help.feature +++ b/cms/djangoapps/contentstore/features/help.feature @@ -51,11 +51,3 @@ Feature: CMS.Help Scenario: Users can access online help on the unit page Given I am in Studio editing a new unit Then I should see online help for "units" - - - Scenario: Users can access online help on the subsection page - Given I have opened a new course section in Studio - And I have added a new subsection - And I click on the subsection - Then I should see online help for "subsections" - diff --git a/cms/djangoapps/contentstore/features/html-editor.py b/cms/djangoapps/contentstore/features/html-editor.py index 6baed40eac..9fdcd0a96b 100644 --- a/cms/djangoapps/contentstore/features/html-editor.py +++ b/cms/djangoapps/contentstore/features/html-editor.py @@ -10,7 +10,7 @@ CODEMIRROR_SELECTOR_PREFIX = "$('iframe').contents().find" @step('I have created a Blank HTML Page$') def i_created_blank_html_page(step): - world.create_course_with_unit() + step.given('I am in Studio editing a new unit') world.create_component_instance( step=step, category='html', @@ -20,7 +20,7 @@ def i_created_blank_html_page(step): @step('I have created a raw HTML component') def i_created_raw_html(step): - world.create_course_with_unit() + step.given('I am in Studio editing a new unit') world.create_component_instance( step=step, category='html', @@ -40,7 +40,7 @@ def i_see_only_the_html_display_name(step): @step('I have created an E-text Written in LaTeX$') def i_created_etext_in_latex(step): - world.create_course_with_unit() + step.given('I am in Studio editing a new unit') step.given('I have enabled latex compiler') world.create_component_instance( step=step, diff --git a/cms/djangoapps/contentstore/features/problem-editor.py b/cms/djangoapps/contentstore/features/problem-editor.py index 44647b5eeb..65cf8c5e95 100644 --- a/cms/djangoapps/contentstore/features/problem-editor.py +++ b/cms/djangoapps/contentstore/features/problem-editor.py @@ -19,13 +19,13 @@ MATLAB_API_KEY = "Matlab API key" @step('I have created a Blank Common Problem$') def i_created_blank_common_problem(step): - world.create_course_with_unit() + step.given('I am in Studio editing a new unit') step.given("I have created another Blank Common Problem") @step('I have created a unit with advanced module "(.*)"$') def i_created_unit_with_advanced_module(step, advanced_module): - world.create_course_with_unit() + step.given('I am in Studio editing a new unit') url = world.browser.url step.given("I select the Advanced Settings") @@ -239,7 +239,7 @@ def enable_latex_compiler(step): @step('I have created a LaTeX Problem') def create_latex_problem(step): - world.create_course_with_unit() + step.given('I am in Studio editing a new unit') step.given('I have enabled latex compiler') world.create_component_instance( step=step, diff --git a/cms/djangoapps/contentstore/features/section.feature b/cms/djangoapps/contentstore/features/section.feature deleted file mode 100644 index 4ad3f8efa3..0000000000 --- a/cms/djangoapps/contentstore/features/section.feature +++ /dev/null @@ -1,44 +0,0 @@ -@shard_2 -Feature: CMS.Create Section - In order offer a course on the edX platform - As a course author - I want to create and edit sections - - Scenario: Add a new section to a course - Given I have opened a new course in Studio - When I click the New Section link - And I enter the section name and click save - Then I see my section on the Courseware page - And I see a release date for my section - And I see a link to create a new subsection - - Scenario: Add a new section (with a quote in the name) to a course (bug #216) - Given I have opened a new course in Studio - When I click the New Section link - And I enter a section name with a quote and click save - Then I see my section name with a quote on the Courseware page - And I click to edit the section name - Then I see the complete section name with a quote in the editor - - Scenario: Edit section release date - Given I have opened a new course in Studio - And I have added a new section - When I click the Edit link for the release date - And I set the section release date to 12/25/2013 - Then the section release date is updated - And I see a "saving" notification - - Scenario: Section name not clickable on editing release date - Given I have opened a new course in Studio - And I have added a new section - When I click the Edit link for the release date - And I click on section name in Section Release Date modal - Then I see no form for editing section name in modal - - Scenario: Delete section - Given I have opened a new course in Studio - And I have added a new section - When I will confirm all alerts - And I press the "section" delete icon - And I confirm the prompt - Then the section does not exist diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py deleted file mode 100644 index eef87e1f4e..0000000000 --- a/cms/djangoapps/contentstore/features/section.py +++ /dev/null @@ -1,142 +0,0 @@ -# pylint: disable=C0111 -# pylint: disable=W0621 - -from lettuce import world, step -from common import * -from nose.tools import assert_equal # pylint: disable=E0611 - - -@step('I click the New Section link$') -def i_click_new_section_link(_step): - link_css = 'a.new-courseware-section-button' - world.css_click(link_css) - - -@step('I enter the section name and click save$') -def i_save_section_name(_step): - save_section_name('My Section') - - -@step('I enter a section name with a quote and click save$') -def i_save_section_name_with_quote(_step): - save_section_name('Section with "Quote"') - - -@step('I have added a new section$') -def i_have_added_new_section(_step): - add_section() - - -@step('I click the Edit link for the release date$') -def i_click_the_edit_link_for_the_release_date(_step): - button_css = 'div.section-published-date a.edit-release-date' - world.css_click(button_css) - - -@step('I set the section release date to ([0-9/-]+)( [0-9:]+)?') -def set_section_release_date(_step, datestring, timestring): - if hasattr(timestring, "strip"): - timestring = timestring.strip() - if not timestring: - timestring = "00:00" - set_date_and_time( - 'input.start-date.date.hasDatepicker', datestring, - 'input.start-time.time.ui-timepicker-input', timestring) - world.browser.click_link_by_text('Save') - - -@step('I see a "(saving|deleting)" notification') -def i_see_a_mini_notification(_step, _type): - saving_css = '.wrapper-notification-mini' - assert world.is_css_present(saving_css) - - -@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') - - -@step('I see my section name with a quote on the Courseware page$') -def i_see_my_section_name_with_quote_on_the_courseware_page(_step): - see_my_section_on_the_courseware_page('Section with "Quote"') - - -@step('I click to edit the section name$') -def i_click_to_edit_section_name(_step): - world.css_click('span.section-name-span') - - -@step('I click on section name in Section Release Date modal$') -def i_click_on_section_name_in_modal(_step): - world.css_click('.modal-window .section-name') - - -@step('I see no form for editing section name in modal$') -def edit_section_name_form_not_exist(_step): - assert world.is_css_not_present('.modal-window .section-name input') - - -@step('I see the complete section name with a quote in the editor$') -def i_see_complete_section_name_with_quote_in_editor(_step): - css = '.section-name-edit input[type=text]' - assert world.is_css_present(css) - assert_equal(world.css_value(css), 'Section with "Quote"') - - -@step('the section does not exist$') -def section_does_not_exist(_step): - css = 'h3[data-name="My Section"]' - assert world.is_css_not_present(css) - - -@step('I see a release date for my section$') -def i_see_a_release_date_for_my_section(_step): - import re - - css = 'span.published-status' - assert world.is_css_present(css) - status_text = world.css_text(css) - - # e.g. 11/06/2012 at 16:25 - msg = 'Release date:' - date_regex = r'(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d\d?, \d{4}' - if not re.search(date_regex, status_text): - print status_text, date_regex - time_regex = r'[0-2]\d:[0-5]\d( \w{3})?' - if not re.search(time_regex, status_text): - print status_text, time_regex - match_string = r'%s\s+%s at %s' % (msg, date_regex, time_regex) - if not re.match(match_string, status_text): - print status_text, match_string - assert re.match(match_string, status_text) - - -@step('I see a link to create a new subsection$') -def i_see_a_link_to_create_a_new_subsection(_step): - css = 'a.new-subsection-item' - assert world.is_css_present(css) - - -@step('the section release date picker is not visible$') -def the_section_release_date_picker_not_visible(_step): - css = 'div.edit-subsection-publish-settings' - assert not world.css_visible(css) - - -@step('the section release date is updated$') -def the_section_release_date_is_updated(_step): - css = 'span.published-status' - status_text = world.css_text(css) - assert_equal(status_text, 'Release date: 12/25/2013 at 00:00 UTC') - - -def save_section_name(name): - name_css = '.new-section-name' - save_css = '.new-section-name-save' - world.css_fill(name_css, name) - world.css_click(save_css) - - -def see_my_section_on_the_courseware_page(name): - section_css = 'span.section-name-span' - assert world.css_has_text(section_css, name) diff --git a/cms/djangoapps/contentstore/features/subsection.feature b/cms/djangoapps/contentstore/features/subsection.feature deleted file mode 100644 index 77440190b3..0000000000 --- a/cms/djangoapps/contentstore/features/subsection.feature +++ /dev/null @@ -1,75 +0,0 @@ -@shard_2 -Feature: CMS.Create Subsection - In order offer a course on the edX platform - As a course author - I want to create and edit subsections - - Scenario: Add a new subsection to a section - Given I have opened a new course section in Studio - When I click the New Subsection link - And I enter the subsection name and click save - Then I see my subsection on the Courseware page - - Scenario: Add a new subsection (with a name containing a quote) to a section (bug #216) - Given I have opened a new course section in Studio - When I click the New Subsection link - And I enter a subsection name with a quote and click save - Then I see my subsection name with a quote on the Courseware page - And I click on the subsection - Then I see the complete subsection name with a quote in the editor - - Scenario: Assign grading type to a subsection and verify it is still shown after refresh (bug #258) - Given I have opened a new course section in Studio - And I have added a new subsection - And I mark it as Homework - Then I see it marked as Homework - And I reload the page - Then I see it marked as Homework - - # Safari has trouble saving the date in Sauce - @skip_safari - Scenario: Set a due date in a different year (bug #256) - Given I have opened a new subsection in Studio - And I set the subsection release date to 12/25/2011 03:00 - And I set the subsection due date to 01/02/2012 04:00 - Then I see the subsection release date is 12/25/2011 03:00 - And I see the subsection due date is 01/02/2012 04:00 - And I reload the page - Then I see the subsection release date is 12/25/2011 03:00 - And I see the subsection due date is 01/02/2012 04:00 - - @skip_safari - Scenario: Set release and due dates of subsection on enter - Given I have opened a new subsection in Studio - And I set the subsection release date on enter to 04/04/2014 03:00 - And I set the subsection due date on enter to 04/04/2014 04:00 - Then I see the subsection release date is 04/04/2014 03:00 - And I see the subsection due date is 04/04/2014 04:00 - And I reload the page - Then I see the subsection release date is 04/04/2014 03:00 - And I see the subsection due date is 04/04/2014 04:00 - - Scenario: Delete a subsection - Given I have opened a new course section in Studio - And I have added a new subsection - And I see my subsection on the Courseware page - When I will confirm all alerts - And I press the "subsection" delete icon - And I confirm the prompt - Then the subsection does not exist - - @skip_safari - Scenario: Sync to Section - Given I have opened a new course section in Studio - And I click the Edit link for the release date - And I set the section release date to 01/02/2103 - And I have added a new subsection - And I click on the subsection - And I set the subsection release date to 06/20/2104 - Then I see the subsection release date is 06/20/2104 - And I reload the page - Then I see the subsection release date is 06/20/2104 - And I click the link to sync release date to section - And I wait for "1" second - And I reload the page - Then I see the subsection release date is 01/02/2103 diff --git a/cms/djangoapps/contentstore/features/subsection.py b/cms/djangoapps/contentstore/features/subsection.py deleted file mode 100644 index 6f8489beb3..0000000000 --- a/cms/djangoapps/contentstore/features/subsection.py +++ /dev/null @@ -1,158 +0,0 @@ -# pylint: disable=C0111 -# pylint: disable=W0621 - -from lettuce import world, step -from common import * -from nose.tools import assert_equal # pylint: disable=E0611 - -############### ACTIONS #################### - - -@step('I have opened a new course section in Studio$') -def i_have_opened_a_new_course_section(step): - open_new_course() - add_section() - - -@step('I have added a new subsection$') -def i_have_added_a_new_subsection(step): - add_subsection() - - -@step('I have opened a new subsection in Studio$') -def i_have_opened_a_new_subsection(step): - step.given('I have opened a new course section in Studio') - step.given('I have added a new subsection') - world.css_click('span.subsection-name-value') - - -@step('I click the New Subsection link') -def i_click_the_new_subsection_link(step): - world.css_click('a.new-subsection-item') - - -@step('I enter the subsection name and click save$') -def i_save_subsection_name(step): - save_subsection_name('Subsection One') - - -@step('I enter a subsection name with a quote and click save$') -def i_save_subsection_name_with_quote(step): - save_subsection_name('Subsection With "Quote"') - - -@step('I click on the subsection$') -def click_on_subsection(step): - world.css_click('span.subsection-name-value') - - -@step('I see the complete subsection name with a quote in the editor$') -def i_see_complete_subsection_name_with_quote_in_editor(step): - css = '.subsection-display-name-input' - assert world.is_css_present(css) - assert_equal(world.css_value(css), 'Subsection With "Quote"') - - -@step('I set the subsection release date to ([0-9/-]+)( [0-9:]+)?') -def set_subsection_release_date(_step, datestring, timestring): - set_subsection_date('input#start_date', datestring, 'input#start_time', timestring) - - -@step('I set the subsection release date on enter to ([0-9/-]+)( [0-9:]+)?') -def set_subsection_release_date_on_enter(_step, datestring, timestring): # pylint: disable-msg=invalid-name - set_subsection_date('input#start_date', datestring, 'input#start_time', timestring, 'ENTER') - - -@step('I set the subsection due date to ([0-9/-]+)( [0-9:]+)?') -def set_subsection_due_date(_step, datestring, timestring, key=None): - if not world.css_visible('input#due_date'): - world.css_click('.due-date-input .set-date') - - assert world.css_visible('input#due_date') - set_subsection_date('input#due_date', datestring, 'input#due_time', timestring, key) - - -@step('I set the subsection due date on enter to ([0-9/-]+)( [0-9:]+)?') -def set_subsection_due_date_on_enter(_step, datestring, timestring): # pylint: disable-msg=invalid-name - set_subsection_due_date(_step, datestring, timestring, 'ENTER') - - -@step('I mark it as Homework$') -def i_mark_it_as_homework(step): - world.css_click('a.menu-toggle') - world.browser.click_link_by_text('Homework') - - -@step('I see it marked as Homework$') -def i_see_it_marked__as_homework(step): - assert_equal(world.css_value(".status-label"), 'Homework') - - -@step('I click the link to sync release date to section') -def click_sync_release_date(step): - world.css_click('.sync-date') - - -############ ASSERTIONS ################### - - -@step('I see my subsection on the Courseware page$') -def i_see_my_subsection_on_the_courseware_page(step): - see_subsection_name('Subsection One') - - -@step('I see my subsection name with a quote on the Courseware page$') -def i_see_my_subsection_name_with_quote_on_the_courseware_page(step): - see_subsection_name('Subsection With "Quote"') - - -@step('the subsection does not exist$') -def the_subsection_does_not_exist(step): - css = 'span.subsection-name' - assert world.is_css_not_present(css) - - -@step('I see the subsection release date is ([0-9/-]+)( [0-9:]+)?') -def i_see_subsection_release(_step, datestring, timestring): - if hasattr(timestring, "strip"): - timestring = timestring.strip() - assert_equal(datestring, get_date('input#start_date')) - if timestring: - assert_equal(timestring, get_date('input#start_time')) - - -@step('I see the subsection due date is ([0-9/-]+)( [0-9:]+)?') -def i_see_subsection_due(_step, datestring, timestring): - if hasattr(timestring, "strip"): - timestring = timestring.strip() - assert_equal(datestring, get_date('input#due_date')) - if timestring: - assert_equal(timestring, get_date('input#due_time')) - - -############ HELPER METHODS ################### -def get_date(css): - return world.css_find(css).first.value.strip() - - -def save_subsection_name(name): - name_css = 'input.new-subsection-name-input' - save_css = 'input.new-subsection-name-save' - world.css_fill(name_css, name) - world.css_click(save_css) - - -def see_subsection_name(name): - css = 'span.subsection-name' - assert world.is_css_present(css) - css = 'span.subsection-name-value' - assert world.css_has_text(css, name) - - -def set_subsection_date(date_css, datestring, time_css, timestring, key=None): - if hasattr(timestring, "strip"): - timestring = timestring.strip() - if not timestring: - timestring = "00:00" - - set_date_and_time(date_css, datestring, time_css, timestring, key) diff --git a/cms/djangoapps/contentstore/features/video.py b/cms/djangoapps/contentstore/features/video.py index 083193760d..ebe94f4a79 100644 --- a/cms/djangoapps/contentstore/features/video.py +++ b/cms/djangoapps/contentstore/features/video.py @@ -31,11 +31,10 @@ def configure_youtube_api(_step, action): raise ValueError('Parameter `action` should be one of "proxies" or "blocks".') @step('I have created a Video component$') -def i_created_a_video_component(_step): - - world.create_course_with_unit() +def i_created_a_video_component(step): + step.given('I am in Studio editing a new unit') world.create_component_instance( - step=_step, + step=step, category='video', ) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index fe17d849c6..1c8a274a18 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1209,7 +1209,10 @@ class ContentStoreTest(ContentStoreTestCase): resp = self._show_course_overview(course.id) self.assertContains( resp, - '
', + '
'.format( + locator='i4x://MITx/999/course/Robot_Super_Course', + course_key='MITx/999/Robot_Super_Course', + ), status_code=200, html=True ) diff --git a/cms/djangoapps/contentstore/tests/utils.py b/cms/djangoapps/contentstore/tests/utils.py index 0682560f70..fcfa7ef979 100644 --- a/cms/djangoapps/contentstore/tests/utils.py +++ b/cms/djangoapps/contentstore/tests/utils.py @@ -102,10 +102,11 @@ class CourseTestCase(ModuleStoreTestCase): """ Add 2 chapters, 4 sections, 8 verticals, 16 problems to self.course (branching 2) """ + user_id = self.user.id def descend(parent, stack): xblock_type = stack.pop(0) for _ in range(2): - child = ItemFactory.create(category=xblock_type, parent_location=parent.location) + child = ItemFactory.create(category=xblock_type, parent_location=parent.location, user_id=user_id) if stack: descend(child, stack) diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 2bc93fcf79..193b0e088d 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -156,6 +156,7 @@ def container_handler(request, usage_key_string): component_templates = get_component_templates(course) ancestor_xblocks = [] parent = get_parent_xblock(xblock) + action = request.REQUEST.get('action', 'view') is_unit_page = is_unit(xblock) unit = xblock if is_unit_page else None @@ -172,7 +173,10 @@ def container_handler(request, usage_key_string): assert subsection is not None, "Could not determine parent subsection from unit " + unicode(unit.location) section = get_parent_xblock(subsection) assert section is not None, "Could not determine ancestor section from unit " + unicode(unit.location) - xblock_info = create_xblock_info(usage_key, xblock) + + # Fetch the XBlock info for use by the container page. Note that it includes information + # about the block's ancestors and siblings for use by the Unit Outline. + xblock_info = create_xblock_info(xblock, include_ancestor_info=is_unit_page) # Create the link for preview. preview_lms_base = settings.FEATURES.get('PREVIEW_LMS_BASE') @@ -198,6 +202,7 @@ def container_handler(request, usage_key_string): return render_to_response('container.html', { 'context_course': course, # Needed only for display of menus at top of page. + 'action': action, 'xblock': xblock, 'xblock_locator': xblock.location, 'unit': unit, diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 7b67dbf6df..dcc802b030 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -55,6 +55,7 @@ from .component import ( ADVANCED_COMPONENT_TYPES, ) from .tasks import rerun_course +from .item import create_xblock_info from opaque_keys.edx.keys import CourseKey from course_creators.views import get_course_creator_status, add_user_with_status_unrequested @@ -210,7 +211,7 @@ def course_handler(request, course_key_string=None): response_format = request.REQUEST.get('format', 'html') if response_format == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'): if request.method == 'GET': - return JsonResponse(_course_json(request, CourseKey.from_string(course_key_string))) + return JsonResponse(_course_outline_json(request, CourseKey.from_string(course_key_string))) elif request.method == 'POST': # not sure if this is only post. If one will have ids, it goes after access return _create_or_rerun_course(request) elif not has_course_access(request.user, CourseKey.from_string(course_key_string)): @@ -230,30 +231,16 @@ def course_handler(request, course_key_string=None): return HttpResponseNotFound() -@login_required -def _course_json(request, course_key): +def _course_outline_json(request, course_key): """ - Returns a JSON overview of a course + Returns a JSON representation of the course module and recursively all of its children. """ course_module = _get_course_module(course_key, request.user, depth=None) - return _xmodule_json(course_module, course_module.id) - - -def _xmodule_json(xmodule, course_id): - """ - Returns a JSON overview of an XModule - """ - is_container = xmodule.has_children - result = { - 'display_name': xmodule.display_name, - 'id': unicode(xmodule.location), - 'category': xmodule.category, - 'is_draft': getattr(xmodule, 'is_draft', False), - 'is_container': is_container, - } - if is_container: - result['children'] = [_xmodule_json(child, course_id) for child in xmodule.get_children()] - return result + return create_xblock_info( + course_module, + include_child_info=True, + include_children_predicate=lambda xblock: not xblock.category == 'vertical' + ) def _accessible_courses_list(request): @@ -384,27 +371,68 @@ def course_index(request, course_key): course_module = _get_course_module(course_key, request.user, depth=3) lms_link = get_lms_link_for_item(course_module.location) sections = course_module.get_children() + course_structure = _course_outline_json(request, course_key) + locator_to_show = request.REQUEST.get('show', None) try: current_action = CourseRerunState.objects.find_first(course_key=course_key, should_display=True) except (ItemNotFoundError, CourseActionStateItemNotFoundError): current_action = None - - return render_to_response('overview.html', { + + return render_to_response('course_outline.html', { 'context_course': course_module, 'lms_link': lms_link, 'sections': sections, + 'course_structure': course_structure, + 'initial_state': course_outline_initial_state(locator_to_show, course_structure) if locator_to_show else None, 'course_graders': json.dumps( CourseGradingModel.fetch(course_key).graders ), - 'new_section_category': 'chapter', - 'new_subsection_category': 'sequential', - 'new_unit_category': 'vertical', - 'category': 'vertical', 'rerun_notification_id': current_action.id if current_action else None, }) +def course_outline_initial_state(locator_to_show, course_structure): + """ + Returns the desired initial state for the course outline view. If the 'show' request parameter + was provided, then the view's initial state will be to have the desired item fully expanded + and to scroll to see the new item. + """ + def find_xblock_info(xblock_info, locator): + """ + Finds the xblock info for the specified locator. + """ + if xblock_info['id'] == locator: + return xblock_info + children = xblock_info['child_info']['children'] if xblock_info['child_info'] else None + if children: + for child_xblock_info in children: + result = find_xblock_info(child_xblock_info, locator) + if result: + return result + return None + + def collect_all_locators(locators, xblock_info): + """ + Collect all the locators for an xblock and its children. + """ + locators.append(xblock_info['id']) + children = xblock_info['child_info']['children'] if xblock_info['child_info'] else None + if children: + for child_xblock_info in children: + collect_all_locators(locators, child_xblock_info) + + selected_xblock_info = find_xblock_info(course_structure, locator_to_show) + if not selected_xblock_info: + return None + expanded_locators = [] + collect_all_locators(expanded_locators, selected_xblock_info) + return { + 'locator_to_show': locator_to_show, + 'expanded_locators': expanded_locators + } + + @expect_json def _create_or_rerun_course(request): """ diff --git a/cms/djangoapps/contentstore/views/helpers.py b/cms/djangoapps/contentstore/views/helpers.py index cefe82d4d4..45c6a8c17e 100644 --- a/cms/djangoapps/contentstore/views/helpers.py +++ b/cms/djangoapps/contentstore/views/helpers.py @@ -4,6 +4,8 @@ Helper methods for Studio views. from __future__ import absolute_import +import urllib + from django.conf import settings from django.http import HttpResponse from django.shortcuts import redirect @@ -74,9 +76,7 @@ def xblock_has_own_studio_page(xblock): 2. Verticals that are either: - themselves treated as units - a direct child of a unit - 3. XBlocks with children, except for: - - sequentials (aka subsections) - - chapters (aka sections) + 3. XBlocks that support children """ category = xblock.category @@ -85,8 +85,6 @@ def xblock_has_own_studio_page(xblock): elif category == 'vertical': parent_xblock = get_parent_xblock(xblock) return is_unit(parent_xblock) if parent_xblock else False - elif category == 'sequential': - return False # All other xblocks with children have their own page return xblock.has_children @@ -99,8 +97,13 @@ def xblock_studio_url(xblock): if not xblock_has_own_studio_page(xblock): return None category = xblock.category - if category in ('course', 'chapter'): + if category == 'course': return reverse_course_url('course_handler', xblock.location.course_key) + elif category in ('chapter', 'sequential'): + return u'{url}?show={usage_key}'.format( + url=reverse_course_url('course_handler', xblock.location.course_key), + usage_key=urllib.quote(unicode(xblock.location)) + ) else: return reverse_usage_url('container_handler', xblock.location) @@ -116,13 +119,33 @@ def xblock_type_display_name(xblock, default_display_name=None): """ if hasattr(xblock, 'category'): - if is_unit(xblock): - return _('Unit') category = xblock.category + if category == 'vertical' and not is_unit(xblock): + return _('Vertical') else: category = xblock + if category == 'chapter': + return _('Section') + elif category == 'sequential': + return _('Subsection') + elif category == 'vertical': + return _('Unit') component_class = XBlock.load_class(category, select=settings.XBLOCK_SELECT_FUNCTION) if hasattr(component_class, 'display_name') and component_class.display_name.default: return _(component_class.display_name.default) else: return default_display_name + + +def xblock_primary_child_category(xblock): + """ + Returns the primary child category for the specified xblock, or None if there is not a primary category. + """ + category = xblock.category + if category == 'course': + return 'chapter' + elif category == 'chapter': + return 'sequential' + elif category == 'sequential': + return 'vertical' + return None diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index 07a7309df2..f7eb285bcc 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -26,27 +26,30 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError from xmodule.modulestore.inheritance import own_metadata from xmodule.x_module import PREVIEW_VIEWS, STUDIO_VIEW, STUDENT_VIEW -from contentstore.utils import compute_publish_state -from xmodule.modulestore import PublishState from django.contrib.auth.models import User from util.date_utils import get_default_time_display from util.json_request import expect_json, JsonResponse from .access import has_course_access -from contentstore.views.helpers import is_unit +from contentstore.views.helpers import is_unit, xblock_studio_url, xblock_primary_child_category, \ + xblock_type_display_name, get_parent_xblock from contentstore.views.preview import get_preview_fragment from edxmako.shortcuts import render_to_string from models.settings.course_grading import CourseGradingModel from cms.lib.xblock.runtime import handler_url, local_resource_url from opaque_keys.edx.keys import UsageKey, CourseKey -__all__ = ['orphan_handler', 'xblock_handler', 'xblock_view_handler'] +__all__ = ['orphan_handler', 'xblock_handler', 'xblock_view_handler', 'xblock_outline_handler'] log = logging.getLogger(__name__) CREATE_IF_NOT_FOUND = ['course_info'] +# Useful constants for defining predicates +NEVER = lambda x: False +ALWAYS = lambda x: True + # In order to allow descriptors to use a handler url, we need to # monkey-patch the x_module library. @@ -87,7 +90,7 @@ def xblock_handler(request, usage_key_string): json: returns representation of the xblock (locator id, data, and metadata). if ?fields=graderType, it returns the graderType for the unit instead of the above. html: returns HTML for rendering the xblock (which includes both the "preview" view and the "editor" view) - PUT or POST + PUT or POST or PATCH json: if xblock locator is specified, update the xblock instance. The json payload can contain these fields, all optional: :data: the new value for the data. @@ -254,6 +257,33 @@ def xblock_view_handler(request, usage_key_string, view_name): return HttpResponse(status=406) +# pylint: disable=unused-argument +@require_http_methods(("GET")) +@login_required +@expect_json +def xblock_outline_handler(request, usage_key_string): + """ + The restful handler for requests for XBlock information about the block and its children. + This is used by the course outline in particular to construct the tree representation of + a course. + """ + usage_key = UsageKey.from_string(usage_key_string) + if not has_course_access(request.user, usage_key.course_key): + raise PermissionDenied() + + response_format = request.REQUEST.get('format', 'html') + if response_format == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'): + store = modulestore() + root_xblock = store.get_item(usage_key) + return JsonResponse(create_xblock_info( + root_xblock, + include_child_info=True, + include_children_predicate=lambda xblock: not xblock.category == 'vertical' + )) + else: + return Http404 + + def _save_item(user, usage_key, data=None, children=None, metadata=None, nullout=None, grader_type=None, publish=None): """ @@ -541,17 +571,25 @@ def _get_module_info(usage_key, user, rewrite_static_links=True): ) # Note that children aren't being returned until we have a use case. - return create_xblock_info(usage_key, module, data, own_metadata(module)) + return create_xblock_info(module, data=data, metadata=own_metadata(module), include_ancestor_info=True) -def create_xblock_info(usage_key, xblock, data=None, metadata=None): +def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=False, include_child_info=False, + include_children_predicate=NEVER): """ Creates the information needed for client-side XBlockInfo. If data or metadata are not specified, their information will not be added (regardless of whether or not the xblock actually has data or metadata). + + There are two optional boolean parameters: + include_ancestor_info - if true, ancestor info is added to the response + include_child_info - if true, direct child info is included in the response + + In addition, an optional include_children_predicate argument can be provided to define whether or + not a particular xblock should have its children included. """ - publish_state = compute_publish_state(xblock) if xblock else None + published = modulestore().has_item(xblock.location, revision=ModuleStoreEnum.RevisionOption.published_only) def safe_get_username(user_id): """ @@ -574,16 +612,68 @@ def create_xblock_info(usage_key, xblock, data=None, metadata=None): "id": unicode(xblock.location), "display_name": xblock.display_name_with_default, "category": xblock.category, - "has_changes": modulestore().has_changes(usage_key), - "published": publish_state in (PublishState.public, PublishState.draft), + "has_changes": modulestore().has_changes(xblock.location), + "published": published, "edited_on": get_default_time_display(xblock.subtree_edited_on) if xblock.subtree_edited_on else None, "edited_by": safe_get_username(xblock.subtree_edited_by), "published_on": get_default_time_display(xblock.published_date) if xblock.published_date else None, "published_by": safe_get_username(xblock.published_by), + 'studio_url': xblock_studio_url(xblock), } if data is not None: xblock_info["data"] = data if metadata is not None: xblock_info["metadata"] = metadata - + if include_ancestor_info: + xblock_info['ancestor_info'] = _create_xblock_ancestor_info(xblock) + if include_child_info and xblock.has_children: + xblock_info['child_info'] = _create_xblock_child_info( + xblock, include_children_predicate=include_children_predicate + ) return xblock_info + + +def _create_xblock_ancestor_info(xblock): + """ + Returns information about the ancestors of an xblock. Note that the direct parent will also return + information about all of its children. + """ + ancestors = [] + + def collect_ancestor_info(ancestor, include_child_info=False): + """ + Collect xblock info regarding the specified xblock and its ancestors. + """ + if ancestor: + direct_children_only = lambda parent: parent == ancestor + ancestors.append(create_xblock_info( + ancestor, + include_child_info=include_child_info, + include_children_predicate=direct_children_only + )) + collect_ancestor_info(get_parent_xblock(ancestor)) + collect_ancestor_info(get_parent_xblock(xblock), include_child_info=True) + return { + 'ancestors': ancestors + } + + +def _create_xblock_child_info(xblock, include_children_predicate=NEVER): + """ + Returns information about the children of an xblock, as well as about the primary category + of xblock expected as children. + """ + child_info = {} + child_category = xblock_primary_child_category(xblock) + if child_category: + child_info = { + 'category': child_category, + 'display_name': xblock_type_display_name(child_category, default_display_name=child_category), + } + if xblock.has_children and include_children_predicate(xblock): + child_info['children'] = [ + create_xblock_info( + child, include_child_info=True, include_children_predicate=include_children_predicate + ) for child in xblock.get_children() + ] + return child_info diff --git a/cms/djangoapps/contentstore/views/tests/test_container_page.py b/cms/djangoapps/contentstore/views/tests/test_container_page.py index 81a272061d..e3fd69d609 100644 --- a/cms/djangoapps/contentstore/views/tests/test_container_page.py +++ b/cms/djangoapps/contentstore/views/tests/test_container_page.py @@ -52,12 +52,15 @@ class ContainerPageTestCase(StudioPageTestCase): 'data-locator="{0}" data-course-key="{0.course_key}">'.format(self.child_container.location) ), expected_breadcrumbs=( - r'\s*Week 1\s*\s*' - r'\s*Lesson 1\s*\s*' - r'\s*Unit\s*' + r'\s*Week 1\s*\s*' + r'\s*Lesson 1\s*\s*' + r'\s*Unit\s*' ).format( course=re.escape(unicode(self.course.id)), unit=re.escape(unicode(self.vertical.location)), + classes='navigation-item navigation-link navigation-parent', + section_parameters=re.escape(u'?show=i4x%3A//MITx/999/chapter/Week_1'), + subsection_parameters=re.escape(u'?show=i4x%3A//MITx/999/sequential/Lesson_1'), ), ) @@ -77,14 +80,17 @@ class ContainerPageTestCase(StudioPageTestCase): 'data-locator="{0}" data-course-key="{0.course_key}">'.format(draft_container.location) ), expected_breadcrumbs=( - r'\s*Week 1\s*\s*' - r'\s*Lesson 1\s*\s*' - r'\s*Unit\s*\s*' - r'\s*Split Test\s*' + r'\s*Week 1\s*\s*' + r'\s*Lesson 1\s*\s*' + r'\s*Unit\s*\s*' + r'\s*Split Test\s*' ).format( course=re.escape(unicode(self.course.id)), unit=re.escape(unicode(self.vertical.location)), - split_test=re.escape(unicode(self.child_container.location)) + split_test=re.escape(unicode(self.child_container.location)), + classes='navigation-item navigation-link navigation-parent', + section_parameters=re.escape(u'?show=i4x%3A//MITx/999/chapter/Week_1'), + subsection_parameters=re.escape(u'?show=i4x%3A//MITx/999/sequential/Lesson_1'), ), ) diff --git a/cms/djangoapps/contentstore/views/tests/test_course_index.py b/cms/djangoapps/contentstore/views/tests/test_course_index.py index cc82dc6fd5..d444742a7c 100644 --- a/cms/djangoapps/contentstore/views/tests/test_course_index.py +++ b/cms/djangoapps/contentstore/views/tests/test_course_index.py @@ -7,7 +7,10 @@ import lxml from contentstore.tests.utils import CourseTestCase from contentstore.utils import reverse_course_url, add_instructor from contentstore.views.access import has_course_access +from contentstore.views.course import course_outline_initial_state from course_action_state.models import CourseRerunState +from contentstore.views.item import create_xblock_info +from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from opaque_keys.edx.locator import CourseLocator from student.tests.factories import UserFactory @@ -190,3 +193,84 @@ class TestCourseIndex(CourseTestCase): self.assert_correct_json_response(child_response) else: self.assertFalse('children' in json_response) + + +class TestCourseOutline(CourseTestCase): + """ + Unit tests for the course outline. + """ + def setUp(self): + """ + Set up the for the course outline tests. + """ + super(TestCourseOutline, self).setUp() + self.chapter = ItemFactory.create( + parent_location=self.course.location, category='chapter', display_name="Week 1" + ) + self.sequential = ItemFactory.create( + parent_location=self.chapter.location, category='sequential', display_name="Lesson 1" + ) + self.vertical = ItemFactory.create( + parent_location=self.sequential.location, category='vertical', display_name='Subsection 1' + ) + self.video = ItemFactory.create( + parent_location=self.vertical.location, category="video", display_name="My Video" + ) + + def test_json_responses(self): + """ + Verify the JSON responses returned for the course. + """ + outline_url = reverse_course_url('course_handler', self.course.id) + resp = self.client.get(outline_url, HTTP_ACCEPT='application/json') + json_response = json.loads(resp.content) + + # First spot check some values in the root response + self.assertEqual(json_response['category'], 'course') + self.assertEqual(json_response['id'], 'i4x://MITx/999/course/Robot_Super_Course') + self.assertEqual(json_response['display_name'], 'Robot Super Course') + self.assertTrue(json_response['published']) + + # Now verify the first child + children = json_response['child_info']['children'] + self.assertTrue(len(children) > 0) + first_child_response = children[0] + self.assertEqual(first_child_response['category'], 'chapter') + self.assertEqual(first_child_response['id'], 'i4x://MITx/999/chapter/Week_1') + self.assertEqual(first_child_response['display_name'], 'Week 1') + self.assertTrue(first_child_response['published']) + self.assertTrue(len(first_child_response['child_info']['children']) > 0) + + # Finally, validate the entire response for consistency + self.assert_correct_json_response(json_response) + + def assert_correct_json_response(self, json_response): + """ + Asserts that the JSON response is syntactically consistent + """ + self.assertIsNotNone(json_response['display_name']) + self.assertIsNotNone(json_response['id']) + self.assertIsNotNone(json_response['category']) + self.assertIsNotNone(json_response['published']) + if json_response.get('child_info', None): + for child_response in json_response['child_info']['children']: + self.assert_correct_json_response(child_response) + + def test_course_outline_initial_state(self): + course_module = modulestore().get_item(self.course.location) + course_structure = create_xblock_info( + course_module, + include_child_info=True, + include_children_predicate=lambda xblock: not xblock.category == 'vertical' + ) + + # Verify that None is returned for a non-existent locator + self.assertIsNone(course_outline_initial_state('no-such-locator', course_structure)) + + # Verify that the correct initial state is returned for the test chapter + chapter_locator = unicode(self.chapter.location) + initial_state = course_outline_initial_state(chapter_locator, course_structure) + self.assertEqual(initial_state['locator_to_show'], chapter_locator) + expanded_locators = initial_state['expanded_locators'] + self.assertIn(unicode(self.sequential.location), expanded_locators) + self.assertIn(unicode(self.vertical.location), expanded_locators) \ No newline at end of file diff --git a/cms/djangoapps/contentstore/views/tests/test_helpers.py b/cms/djangoapps/contentstore/views/tests/test_helpers.py index 4df6ce5241..d9e33f2be4 100644 --- a/cms/djangoapps/contentstore/views/tests/test_helpers.py +++ b/cms/djangoapps/contentstore/views/tests/test_helpers.py @@ -22,12 +22,17 @@ class HelpersTestCase(CourseTestCase): chapter = ItemFactory.create(parent_location=self.course.location, category='chapter', display_name="Week 1") self.assertEqual(xblock_studio_url(chapter), - u'/course/MITx/999/Robot_Super_Course') + u'/course/MITx/999/Robot_Super_Course?show={escaped_usage_key}'.format( + escaped_usage_key='i4x%3A//MITx/999/chapter/Week_1' + )) - # Verify lesson URL + # Verify sequential URL sequential = ItemFactory.create(parent_location=chapter.location, category='sequential', display_name="Lesson 1") - self.assertIsNone(xblock_studio_url(sequential)) + self.assertEqual(xblock_studio_url(sequential), + u'/course/MITx/999/Robot_Super_Course?show={escaped_usage_key}'.format( + escaped_usage_key='i4x%3A//MITx/999/sequential/Lesson_1' + )) # Verify unit URL vertical = ItemFactory.create(parent_location=sequential.location, category='vertical', @@ -48,13 +53,25 @@ class HelpersTestCase(CourseTestCase): def test_xblock_type_display_name(self): + # Verify chapter type display name chapter = ItemFactory.create(parent_location=self.course.location, category='chapter') + self.assertEqual(xblock_type_display_name(chapter), u'Section') + self.assertEqual(xblock_type_display_name('chapter'), u'Section') + + # Verify sequential type display name sequential = ItemFactory.create(parent_location=chapter.location, category='sequential') + self.assertEqual(xblock_type_display_name(sequential), u'Subsection') + self.assertEqual(xblock_type_display_name('sequential'), u'Subsection') # Verify unit type display names vertical = ItemFactory.create(parent_location=sequential.location, category='vertical') self.assertEqual(xblock_type_display_name(vertical), u'Unit') - self.assertIsNone(xblock_type_display_name('vertical')) + self.assertEqual(xblock_type_display_name('vertical'), u'Unit') + + # Verify child vertical type display name + child_vertical = ItemFactory.create(parent_location=vertical.location, category='vertical', + display_name='Child Vertical') + self.assertEqual(xblock_type_display_name(child_vertical), u'Vertical') # Verify video type display names video = ItemFactory.create(parent_location=vertical.location, category="video") diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py index fb4c37075c..8f1afc7820 100644 --- a/cms/djangoapps/contentstore/views/tests/test_item.py +++ b/cms/djangoapps/contentstore/views/tests/test_item.py @@ -19,12 +19,14 @@ from contentstore.views.component import ( component_handler, get_component_templates ) +from contentstore.views.item import create_xblock_info, ALWAYS from contentstore.tests.utils import CourseTestCase from student.tests.factories import UserFactory from xmodule.capa_module import CapaDescriptor from xmodule.modulestore import PublishState from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.factories import ItemFactory from xmodule.x_module import STUDIO_VIEW, STUDENT_VIEW from xblock.exceptions import NoSuchHandlerError from opaque_keys.edx.keys import UsageKey, CourseKey @@ -1025,3 +1027,170 @@ class TestComponentTemplates(CourseTestCase): self.assertIsNotNone(ora_template) self.assertEqual(ora_template.get('category'), 'openassessment') self.assertIsNone(ora_template.get('boilerplate_name', None)) + + +class TestXBlockInfo(ItemTest): + """ + Unit tests for XBlock's outline handling. + """ + def setUp(self): + super(TestXBlockInfo, self).setUp() + user_id = self.user.id + self.chapter = ItemFactory.create( + parent_location=self.course.location, category='chapter', display_name="Week 1", user_id=user_id + ) + self.sequential = ItemFactory.create( + parent_location=self.chapter.location, category='sequential', display_name="Lesson 1", user_id=user_id + ) + self.vertical = ItemFactory.create( + parent_location=self.sequential.location, category='vertical', display_name='Unit 1', user_id=user_id + ) + self.video = ItemFactory.create( + parent_location=self.vertical.location, category='video', display_name='My Video', user_id=user_id + ) + + def test_json_responses(self): + outline_url = reverse_usage_url('xblock_outline_handler', self.usage_key) + resp = self.client.get(outline_url, HTTP_ACCEPT='application/json') + json_response = json.loads(resp.content) + self.validate_course_xblock_info(json_response) + + def test_chapter_xblock_info(self): + chapter = modulestore().get_item(self.chapter.location) + xblock_info = create_xblock_info( + chapter, + include_child_info=True, + include_children_predicate=ALWAYS, + ) + self.validate_chapter_xblock_info(xblock_info) + + def test_sequential_xblock_info(self): + sequential = modulestore().get_item(self.sequential.location) + xblock_info = create_xblock_info( + sequential, + include_child_info=True, + include_children_predicate=ALWAYS, + ) + self.validate_sequential_xblock_info(xblock_info) + + def test_vertical_xblock_info(self): + vertical = modulestore().get_item(self.vertical.location) + xblock_info = create_xblock_info( + vertical, + include_child_info=True, + include_children_predicate=ALWAYS, + include_ancestor_info=True + ) + self.validate_vertical_xblock_info(xblock_info) + + def test_component_xblock_info(self): + video = modulestore().get_item(self.video.location) + xblock_info = create_xblock_info( + video, + include_child_info=True, + include_children_predicate=ALWAYS + ) + self.validate_component_xblock_info(xblock_info) + + def validate_course_xblock_info(self, xblock_info, has_child_info=True): + """ + Validate that the xblock info is correct for the test course. + """ + self.assertEqual(xblock_info['category'], 'course') + self.assertEqual(xblock_info['id'], 'i4x://MITx/999/course/Robot_Super_Course') + self.assertEqual(xblock_info['display_name'], 'Robot Super Course') + self.assertTrue(xblock_info['published']) + + # Finally, validate the entire response for consistency + self.validate_xblock_info_consistency(xblock_info, has_child_info=has_child_info) + + def validate_chapter_xblock_info(self, xblock_info, has_child_info=True): + """ + Validate that the xblock info is correct for the test chapter. + """ + self.assertEqual(xblock_info['category'], 'chapter') + self.assertEqual(xblock_info['id'], 'i4x://MITx/999/chapter/Week_1') + self.assertEqual(xblock_info['display_name'], 'Week 1') + self.assertTrue(xblock_info['published']) + self.assertEqual(xblock_info['edited_by'], 'testuser') + + # Finally, validate the entire response for consistency + self.validate_xblock_info_consistency(xblock_info, has_child_info=has_child_info) + + def validate_sequential_xblock_info(self, xblock_info, has_child_info=True): + """ + Validate that the xblock info is correct for the test chapter. + """ + self.assertEqual(xblock_info['category'], 'sequential') + self.assertEqual(xblock_info['id'], 'i4x://MITx/999/sequential/Lesson_1') + self.assertEqual(xblock_info['display_name'], 'Lesson 1') + self.assertTrue(xblock_info['published']) + self.assertEqual(xblock_info['edited_by'], 'testuser') + + # Finally, validate the entire response for consistency + self.validate_xblock_info_consistency(xblock_info, has_child_info=has_child_info) + + def validate_vertical_xblock_info(self, xblock_info): + """ + Validate that the xblock info is correct for the test vertical. + """ + self.assertEqual(xblock_info['category'], 'vertical') + self.assertEqual(xblock_info['id'], 'i4x://MITx/999/vertical/Unit_1') + self.assertEqual(xblock_info['display_name'], 'Unit 1') + self.assertTrue(xblock_info['published']) + self.assertEqual(xblock_info['edited_by'], 'testuser') + + # Validate that the correct ancestor info has been included + ancestor_info = xblock_info.get('ancestor_info', None) + self.assertIsNotNone(ancestor_info) + ancestors = ancestor_info['ancestors'] + self.assertEqual(len(ancestors), 3) + self.validate_sequential_xblock_info(ancestors[0], has_child_info=True) + self.validate_chapter_xblock_info(ancestors[1], has_child_info=False) + self.validate_course_xblock_info(ancestors[2], has_child_info=False) + + # Finally, validate the entire response for consistency + self.validate_xblock_info_consistency(xblock_info, has_child_info=True, has_ancestor_info=True) + + def validate_component_xblock_info(self, xblock_info): + """ + Validate that the xblock info is correct for the test component. + """ + self.assertEqual(xblock_info['category'], 'video') + self.assertEqual(xblock_info['id'], 'i4x://MITx/999/video/My_Video') + self.assertEqual(xblock_info['display_name'], 'My Video') + self.assertTrue(xblock_info['published']) + self.assertEqual(xblock_info['edited_by'], 'testuser') + + # Finally, validate the entire response for consistency + self.validate_xblock_info_consistency(xblock_info) + + def validate_xblock_info_consistency(self, xblock_info, has_ancestor_info=False, has_child_info=False): + """ + Validate that the xblock info is internally consistent. + """ + self.assertIsNotNone(xblock_info['display_name']) + self.assertIsNotNone(xblock_info['id']) + self.assertIsNotNone(xblock_info['category']) + self.assertIsNotNone(xblock_info['published']) + self.assertEqual(xblock_info['edited_by'], 'testuser') + if has_ancestor_info: + self.assertIsNotNone(xblock_info.get('ancestor_info', None)) + ancestors = xblock_info['ancestor_info']['ancestors'] + for ancestor in xblock_info['ancestor_info']['ancestors']: + self.validate_xblock_info_consistency( + ancestor, + has_child_info=(ancestor == ancestors[0]) # Only the direct ancestor includes children + ) + else: + self.assertIsNone(xblock_info.get('ancestor_info', None)) + if has_child_info: + self.assertIsNotNone(xblock_info.get('child_info', None)) + if xblock_info['child_info'].get('children', None): + for child_response in xblock_info['child_info']['children']: + self.validate_xblock_info_consistency( + child_response, + has_child_info=(not child_response.get('child_info', None) == None) + ) + else: + self.assertIsNone(xblock_info.get('child_info', None)) diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee index 38d4444059..59df0f722b 100644 --- a/cms/static/coffee/spec/main.coffee +++ b/cms/static/coffee/spec/main.coffee @@ -202,10 +202,8 @@ define([ "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", "js/spec/video/transcripts/utils_spec", "js/spec/video/transcripts/editor_spec", @@ -214,23 +212,26 @@ define([ "js/spec/models/component_template_spec", "js/spec/models/explicit_url_spec", + "js/spec/models/group_configuration_spec", "js/spec/utils/drag_and_drop_spec", "js/spec/utils/handle_iframe_binding_spec", "js/spec/utils/module_spec", - "js/spec/views/baseview_spec", "js/spec/views/paging_spec", "js/spec/views/assets_spec", - "js/spec/views/group_configuration_spec", - + "js/spec/views/baseview_spec", "js/spec/views/container_spec", + "js/spec/views/group_configuration_spec", + "js/spec/views/paging_spec", + "js/spec/views/unit_outline_spec", "js/spec/views/xblock_spec", "js/spec/views/xblock_editor_spec", "js/spec/views/pages/container_spec", "js/spec/views/pages/container_subviews_spec", "js/spec/views/pages/group_configurations_spec", + "js/spec/views/pages/course_outline_spec", "js/spec/views/modals/base_modal_spec", "js/spec/views/modals/edit_xblock_spec", diff --git a/cms/static/coffee/spec/views/section_spec.coffee b/cms/static/coffee/spec/views/section_spec.coffee deleted file mode 100644 index 6b8175c096..0000000000 --- a/cms/static/coffee/spec/views/section_spec.coffee +++ /dev/null @@ -1,85 +0,0 @@ -define ["js/models/section", "js/views/section_show", "js/views/section_edit", "js/spec_helpers/create_sinon"], (Section, SectionShow, SectionEdit, create_sinon) -> - - describe "SectionShow", -> - describe "Basic", -> - beforeEach -> - spyOn(SectionShow.prototype, "switchToEditView") - .andCallThrough() - @model = new Section({ - id: 42 - name: "Life, the Universe, and Everything" - }) - @view = new SectionShow({model: @model}) - @view.render() - - it "should contain the model name", -> - expect(@view.$el).toHaveText(@model.get('name')) - - it "should call switchToEditView when clicked", -> - @view.$el.click() - expect(@view.switchToEditView).toHaveBeenCalled() - - it "should pass the same element to SectionEdit when switching views", -> - spyOn(SectionEdit.prototype, 'initialize').andCallThrough() - @view.switchToEditView() - expect(SectionEdit.prototype.initialize).toHaveBeenCalled() - expect(SectionEdit.prototype.initialize.mostRecentCall.args[0].el).toEqual(@view.el) - - describe "SectionEdit", -> - describe "Basic", -> - tpl = readFixtures('section-name-edit.underscore') - feedback_tpl = readFixtures('system-feedback.underscore') - - beforeEach -> - setFixtures($(" + + +<%block name="header_extras"> +% for template_name in ['course-outline', 'xblock-string-field-editor']: + +% endfor + + +<%block name="content"> +
+
+

+ ${_("Content")} + > ${_("Course Outline")} +

+ + +
+
+ +
+
+
+
+ <% + course_locator = context_course.location + %> +
+
+
+
+

${_("Loading...")}

+
+
+ +
+
+
+ diff --git a/cms/templates/js/course-outline.underscore b/cms/templates/js/course-outline.underscore new file mode 100644 index 0000000000..fd10856d0c --- /dev/null +++ b/cms/templates/js/course-outline.underscore @@ -0,0 +1,81 @@ +<% if (parentInfo) { %> +
  • + +
    +
    + <% if (includesChildren) { %> +

    + + <% } else { %> +

    + <% } %> + + <% if (xblockInfo.get('category') === 'vertical') { %> + <%= xblockInfo.get('display_name') %> + <% } else { %> + + <%= xblockInfo.get('display_name') %> + + <% } %> +

    + + +
    +
    + <% if (xblockInfo.get('edited_on')) { %> +
    + <% if (xblockInfo.get('published')) { %> + + <%= gettext('Released:') %> Dec 31, 2015 at 21:00 UTC + <% } else { %> + + <%= gettext('Scheduled:') %> Dec 31, 2015 at 21:00 UTC + <% } %> +
    + <% } %> + + +
    +
      +
    +
    +
    +
    +<% } %> + + <% if (!parentInfo && xblockInfo.get('child_info') && xblockInfo.get('child_info').children.length === 0) { %> +
    +

    <%= gettext("You haven't added any content to this course yet.") %> + + <%= addChildLabel %> + +

    +
    + <% } else { %> +
      +
    + + <% if (childType) { %> + + <% } %> + <% } %> + +<% if (parentInfo) { %> +
  • +<% } %> diff --git a/cms/templates/js/mock/mock-container-page.underscore b/cms/templates/js/mock/mock-container-page.underscore index 8509df0bfd..cdebb424ea 100644 --- a/cms/templates/js/mock/mock-container-page.underscore +++ b/cms/templates/js/mock/mock-container-page.underscore @@ -6,8 +6,8 @@ Unit 1 -
    -

    Test Container

    +
    +

    Test Container

    @@ -62,31 +62,10 @@ Tip: ${_("Use this ID to link to this unit from other places in your course")}

    -
    +
    Unit Tree Location
    -
      -
    1. - - Test Section - -
        -
      1. -
        - Test Subsection -
        -
          -
        1. - -
        -
      2. -
      -
    2. -
    +
    +
    diff --git a/cms/templates/js/mock/mock-course-outline-page.underscore b/cms/templates/js/mock/mock-course-outline-page.underscore new file mode 100644 index 0000000000..54186ca4f6 --- /dev/null +++ b/cms/templates/js/mock/mock-course-outline-page.underscore @@ -0,0 +1,62 @@ +
    + +
    +
    +

    + Content + > Course Outline +

    + + +
    +
    + +
    +
    +
    +
    + +
    +
    +

    Loading...

    +
    +
    + +
    +
    +
    +
    +
    diff --git a/cms/templates/js/unit-outline.underscore b/cms/templates/js/unit-outline.underscore new file mode 100644 index 0000000000..70d3cf4123 --- /dev/null +++ b/cms/templates/js/unit-outline.underscore @@ -0,0 +1,27 @@ +<% if (parentInfo) { %> +
  • + +<% } %> + +
      +
    + + <% if (childType) { %> + + <% } %> + +<% if (parentInfo) { %> +
  • +<% } %> diff --git a/cms/templates/js/xblock-outline.underscore b/cms/templates/js/xblock-outline.underscore new file mode 100644 index 0000000000..0d7e02fdc6 --- /dev/null +++ b/cms/templates/js/xblock-outline.underscore @@ -0,0 +1,82 @@ +<% if (parentInfo) { %> +
  • + + +
    +
    + <% if (includesChildren) { %> +

    + + <% } else { %> +

    + <% } %> + + <% if (xblockInfo.get('studio_url') && xblockInfo.get('category') !== 'chapter') { %> + <%= xblockInfo.get('display_name') %> + <% } else { %> + + <%= xblockInfo.get('display_name') %> + + <% } %> +

    + +
    + +
    +
    +
    + <% if (xblockInfo.get('release_date')) { %> +
    + + <%= gettext('Released:') %> <%= xblockInfo.get('release_date') %> +
    + <% } %> + + +
    +
      +
    +
    +
    +
    +<% } %> + <% if (!parentInfo && xblockInfo.get('child_info') && xblockInfo.get('child_info').children.length === 0) { %> +
    +

    <%= gettext("You haven't added any content to this course yet.") %> + + <%= addChildLabel %> + +

    +
    + <% } else { %> +
      +
    + + <% if (childType) { %> + + <% } %> + <% } %> + +<% if (parentInfo) { %> + +
  • +<% } %> diff --git a/cms/templates/js/xblock-string-field-editor.underscore b/cms/templates/js/xblock-string-field-editor.underscore index 60a8aa42ff..9eb7a41f6d 100644 --- a/cms/templates/js/xblock-string-field-editor.underscore +++ b/cms/templates/js/xblock-string-field-editor.underscore @@ -1,3 +1,4 @@ -
    - -
    + + + diff --git a/cms/templates/overview.html b/cms/templates/overview.html deleted file mode 100644 index 85bf88693c..0000000000 --- a/cms/templates/overview.html +++ /dev/null @@ -1,312 +0,0 @@ -<%inherit file="base.html" /> -<%def name="online_help_token()"><% return "outline" %> -<%! - import logging - from util.date_utils import get_default_time_display - from django.utils.translation import ugettext as _ - from contentstore.utils import reverse_usage_url -%> -<%block name="title">${_("Course Outline")} -<%block name="bodyclass">is-signedin course view-outline - -<%namespace name='static' file='static_content.html'/> -<%namespace name="units" file="widgets/units.html" /> - -<%block name="jsextra"> - - - - - - - -<%block name="header_extras"> - - - - - - - - - - -<%block name="content"> -
    -
    -

    - ${_("Content")} - > ${_("Course Outline")} -

    - - -
    -
    - -
    -
    -
    - -
    - <% - course_locator = context_course.location - %> -
    - % for section in sections: - <% - section_locator = section.location - %> -
    - <%include file="widgets/_ui-dnd-indicator-before.html" /> - -
    - ${_('Expand/collapse this section')} - -
    -

    -
    -
    - -
      - -
    • - -
    • -
    • - ${_('Delete section')} -
    • -
    • - ${_("Drag to reorder section")} -
    • -
    -
    -
    -
    - -
      - % for subsection in section.get_children(): - <% - subsection_locator = subsection.location - %> - - % endfor -
    1. - <%include file="widgets/_ui-dnd-indicator-initial.html" /> -
    2. -
    - - - -
    - - <%include file="widgets/_ui-dnd-indicator-after.html" /> -
    - % endfor -
    -
    - -
    - -
    -
    -
    - - - - - - diff --git a/cms/urls.py b/cms/urls.py index b09092fc2c..e352d42ae2 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -82,6 +82,7 @@ urlpatterns += patterns( url(r'^import/{}$'.format(settings.COURSE_KEY_PATTERN), 'import_handler'), url(r'^import_status/{}/(?P.+)$'.format(settings.COURSE_KEY_PATTERN), 'import_status_handler'), url(r'^export/{}$'.format(settings.COURSE_KEY_PATTERN), 'export_handler'), + url(r'^xblock/outline/{}$'.format(settings.USAGE_KEY_PATTERN), 'xblock_outline_handler'), url(r'^xblock/{}/(?P[^/]+)$'.format(settings.USAGE_KEY_PATTERN), 'xblock_view_handler'), url(r'^xblock/{}?$'.format(settings.USAGE_KEY_PATTERN), 'xblock_handler'), url(r'^tabs/{}$'.format(settings.COURSE_KEY_PATTERN), 'tabs_handler'), diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py index 037e84d578..6574cd3855 100644 --- a/common/djangoapps/terrain/ui_helpers.py +++ b/common/djangoapps/terrain/ui_helpers.py @@ -37,6 +37,11 @@ REQUIREJS_WAIT = { "jquery", "js/base", "js/models/course", "js/models/settings/advanced", "js/views/settings/advanced", "codemirror"], + # Unit page + re.compile('^Unit \|'): [ + "jquery", "js/base", "js/models/xblock_info", "js/views/pages/container", + "js/collections/component_template", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"], + # Content - Outline # Note that calling your org, course number, or display name, 'course' will mess this up re.compile('^Course Outline \|'): [ diff --git a/common/test/acceptance/pages/studio/overview.py b/common/test/acceptance/pages/studio/overview.py index bb48faf5a6..1e69ff8b84 100644 --- a/common/test/acceptance/pages/studio/overview.py +++ b/common/test/acceptance/pages/studio/overview.py @@ -6,12 +6,54 @@ from bok_choy.promise import EmptyPromise from .course_page import CoursePage from .container import ContainerPage +from .utils import set_input_value_and_save -class CourseOutlineContainer(object): +class CourseOutlineItem(object): + """ + A mixin class for any :class:`PageObject` shown in a course outline. + """ + BODY_SELECTOR = None + NAME_SELECTOR = '.xblock-title .xblock-field-value' + NAME_INPUT_SELECTOR = '.xblock-title .xblock-field-input' + + def __repr__(self): + return "{}(, {!r})".format(self.__class__.__name__, self.locator) + + def _bounded_selector(self, selector): + """ + Returns `selector`, but limited to this particular `CourseOutlineItem` context + """ + return '{}[data-locator="{}"] {}'.format( + self.BODY_SELECTOR, + self.locator, + selector + ) + + @property + def name(self): + """ + Returns the display name of this object. + """ + name_element = self.q(css=self._bounded_selector(self.NAME_SELECTOR)).first + if name_element: + return name_element.text[0] + else: + return None + + def change_name(self, new_name): + """ + Changes the container's name. + """ + self.q(css=self._bounded_selector(self.NAME_SELECTOR)).first.click() + set_input_value_and_save(self, self._bounded_selector(self.NAME_INPUT_SELECTOR), new_name) + self.wait_for_ajax() + + +class CourseOutlineContainer(CourseOutlineItem): """ A mixin to a CourseOutline page object that adds the ability to load - a child page object by title. + a child page object by title or by index. CHILD_CLASS must be a :class:`CourseOutlineChild` subclass. """ @@ -33,15 +75,24 @@ class CourseOutlineContainer(object): ).attrs('data-locator')[0] ) + def child_at(self, index, child_class=None): + """ + Returns the child at the specified index. + :type self: object + """ + if not child_class: + child_class = self.CHILD_CLASS -class CourseOutlineChild(PageObject): - """ - A mixin to a CourseOutline page object that will be used as a child of - :class:`CourseOutlineContainer`. - """ - NAME_SELECTOR = None - BODY_SELECTOR = None + return child_class( + self.browser, + self.q(css=child_class.BODY_SELECTOR).attrs('data-locator')[index] + ) + +class CourseOutlineChild(PageObject, CourseOutlineItem): + """ + A page object that will be used as a child of :class:`CourseOutlineContainer`. + """ def __init__(self, browser, locator): super(CourseOutlineChild, self).__init__(browser) self.locator = locator @@ -49,38 +100,14 @@ class CourseOutlineChild(PageObject): def is_browser_on_page(self): return self.q(css='{}[data-locator="{}"]'.format(self.BODY_SELECTOR, self.locator)).present - @property - def name(self): - """ - Return the display name of this object. - """ - titles = self.q(css=self._bounded_selector(self.NAME_SELECTOR)).text - if titles: - return titles[0] - else: - return None - - def __repr__(self): - return "{}(, {!r})".format(self.__class__.__name__, self.locator) - - def _bounded_selector(self, selector): - """ - Return `selector`, but limited to this particular `CourseOutlineChild` context - """ - return '{}[data-locator="{}"] {}'.format( - self.BODY_SELECTOR, - self.locator, - selector - ) - class CourseOutlineUnit(CourseOutlineChild): """ PageObject that wraps a unit link on the Studio Course Overview page. """ url = None - BODY_SELECTOR = '.courseware-unit' - NAME_SELECTOR = '.unit-name' + BODY_SELECTOR = '.outline-item-unit' + NAME_SELECTOR = '.xblock-title a' def go_to(self): """ @@ -88,7 +115,7 @@ class CourseOutlineUnit(CourseOutlineChild): an initialized :class:`.ContainerPage` for that unit. """ return ContainerPage(self.browser, self.locator).visit() - + def is_browser_on_page(self): return self.q(css=self.BODY_SELECTOR).present @@ -99,8 +126,7 @@ class CourseOutlineSubsection(CourseOutlineChild, CourseOutlineContainer): """ url = None - BODY_SELECTOR = '.courseware-subsection' - NAME_SELECTOR = '.subsection-name-value' + BODY_SELECTOR = '.outline-item-subsection' CHILD_CLASS = CourseOutlineUnit def unit(self, title): @@ -116,15 +142,12 @@ class CourseOutlineSubsection(CourseOutlineChild, CourseOutlineContainer): self.browser.execute_script("jQuery.fx.off = true;") def subsection_expanded(): - return all( - self.q(css=self._bounded_selector('.new-unit-item')) - .map(lambda el: el.is_displayed()) - .results - ) + add_button = self.q(css=self._bounded_selector('.add-button')).first.results + return add_button and add_button[0].is_displayed() currently_expanded = subsection_expanded() - self.q(css=self._bounded_selector('.expand-collapse')).first.click() + self.q(css=self._bounded_selector('.ui-toggle-expansion')).first.click() EmptyPromise( lambda: subsection_expanded() != currently_expanded, @@ -139,8 +162,7 @@ class CourseOutlineSection(CourseOutlineChild, CourseOutlineContainer): :class`.PageObject` that wraps a section block on the Studio Course Overview page. """ url = None - BODY_SELECTOR = '.courseware-section' - NAME_SELECTOR = '.section-name-span' + BODY_SELECTOR = '.outline-item-section' CHILD_CLASS = CourseOutlineSubsection def subsection(self, title): @@ -165,6 +187,12 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer): Return the :class:`.CourseOutlineSection` with the title `title`. """ return self.child(title) + + def section_at(self, index): + """ + Returns the :class:`.CourseOutlineSection` at the specified index. + """ + return self.child_at(index) def click_section_name(self, parent_css=''): """ diff --git a/common/test/acceptance/pages/studio/utils.py b/common/test/acceptance/pages/studio/utils.py index 8075ce386b..7bb43b2b10 100644 --- a/common/test/acceptance/pages/studio/utils.py +++ b/common/test/acceptance/pages/studio/utils.py @@ -1,7 +1,10 @@ """ Utility methods useful for Studio page tests. """ +from selenium.webdriver.common.action_chains import ActionChains +from selenium.webdriver.common.keys import Keys from bok_choy.promise import EmptyPromise + from ...tests.helpers import disable_animations @@ -122,3 +125,17 @@ def confirm_prompt(page, cancel=False): confirmation_button_css = '.prompt .action-' + ('secondary' if cancel else 'primary') page.wait_for_element_visibility(confirmation_button_css, 'Confirmation button is visible') click_css(page, confirmation_button_css, require_notification=(not cancel)) + + +def set_input_value_and_save(page, css, value): + """ + Sets the text field with given label (display name) to the specified value, and presses Save. + """ + input_element = page.q(css=css).results[0] + # Click in the input to give it the focus + action = ActionChains(page.browser).click(input_element) + # Delete all of the characters that are currently there + for _x in range(0, len(input_element.get_attribute('value'))): + action = action.send_keys(Keys.BACKSPACE) + # Send the new text, then hit the enter key so that the change event is triggered). + action.send_keys(value).send_keys(Keys.ENTER).perform() diff --git a/common/test/acceptance/tests/test_studio_general.py b/common/test/acceptance/tests/test_studio_general.py index 9e72743b7e..e888f9c4be 100644 --- a/common/test/acceptance/tests/test_studio_general.py +++ b/common/test/acceptance/tests/test_studio_general.py @@ -142,23 +142,25 @@ class CourseSectionTest(StudioCourseTest): """ Check that section name is editable on course outline page. """ - section_name = self.course_outline_page.get_section_name()[0] - self.assertEqual(section_name, "Test Section") - self.course_outline_page.change_section_name("Test Section New") - section_name = self.course_outline_page.get_section_name(page_refresh=True)[0] - self.assertEqual(section_name, "Test Section New") + new_name = u"Test Section New" + section = self.course_outline_page.section_at(0) + self.assertEqual(section.name, u"Test Section") + section.change_name(new_name) + self.browser.refresh() + self.assertEqual(section.name, new_name) - def test_section_name_not_editable_inside_modal(self): - """ - Check that section name is not editable inside "Section Release Date" modal on course outline page. - """ - parent_css='div.modal-window' - self.course_outline_page.click_release_date() - section_name = self.course_outline_page.get_section_name(parent_css)[0] - self.assertEqual(section_name, '"Test Section"') - self.course_outline_page.click_section_name(parent_css) - section_name_edit_form = self.course_outline_page.section_name_edit_form_present(parent_css) - self.assertFalse(section_name_edit_form) + # TODO: re-enable when release date support is added back + # def test_section_name_not_editable_inside_modal(self): + # """ + # Check that section name is not editable inside "Section Release Date" modal on course outline page. + # """ + # parent_css='div.modal-window' + # self.course_outline_page.click_release_date() + # section_name = self.course_outline_page.get_section_name(parent_css)[0] + # self.assertEqual(section_name, '"Test Section"') + # self.course_outline_page.click_section_name(parent_css) + # section_name_edit_form = self.course_outline_page.section_name_edit_form_present(parent_css) + # self.assertFalse(section_name_edit_form) @attr('shard_1')