Commit f061bbc0 by Andy Armstrong Committed by cahrens

Backbone version of the course outline page

STUD-1726
parent 49e0c642
......@@ -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
......
......@@ -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')
......
@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
......
......@@ -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 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 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 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 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 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):
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'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'all sections are collapsed$')
def all_sections_are_collapsed(step):
@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)):
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")
......
......@@ -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)
......@@ -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')
......@@ -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',
......
......@@ -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
......
......@@ -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)
......
......@@ -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"
......@@ -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,
......
......@@ -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,
......
@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
# 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)
@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
# 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)
......@@ -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',
)
......
......@@ -1209,7 +1209,10 @@ class ContentStoreTest(ContentStoreTestCase):
resp = self._show_course_overview(course.id)
self.assertContains(
resp,
'<article class="courseware-overview" data-locator="i4x://MITx/999/course/Robot_Super_Course" data-course-key="MITx/999/Robot_Super_Course">',
'<article class="course-outline" data-locator="{locator}" data-course-key="{course_key}">'.format(
locator='i4x://MITx/999/course/Robot_Super_Course',
course_key='MITx/999/Robot_Super_Course',
),
status_code=200,
html=True
)
......
......@@ -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)
......
......@@ -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,
......
......@@ -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):
"""
......
......@@ -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
......@@ -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
......@@ -52,12 +52,15 @@ class ContainerPageTestCase(StudioPageTestCase):
'data-locator="{0}" data-course-key="{0.course_key}">'.format(self.child_container.location)
),
expected_breadcrumbs=(
r'<a href="/course/{course}" class="navigation-item navigation-link navigation-parent">\s*Week 1\s*</a>\s*'
r'<span class="navigation-item navigation-parent">\s*Lesson 1\s*</span>\s*'
r'<a href="/container/{unit}" class="navigation-item navigation-link navigation-parent">\s*Unit\s*</a>'
r'<a href="/course/{course}{section_parameters}" class="{classes}">\s*Week 1\s*</a>\s*'
r'<a href="/course/{course}{subsection_parameters}" class="{classes}">\s*Lesson 1\s*</a>\s*'
r'<a href="/container/{unit}" class="{classes}">\s*Unit\s*</a>'
).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'<a href="/course/{course}" class="navigation-item navigation-link navigation-parent">\s*Week 1\s*</a>\s*'
r'<span class="navigation-item navigation-parent">\s*Lesson 1\s*</span>\s*'
r'<a href="/container/{unit}" class="navigation-item navigation-link navigation-parent">\s*Unit\s*</a>\s*'
r'<a href="/container/{split_test}" class="navigation-item navigation-link navigation-parent">\s*Split Test\s*</a>'
r'<a href="/course/{course}{section_parameters}" class="{classes}">\s*Week 1\s*</a>\s*'
r'<a href="/course/{course}{subsection_parameters}" class="{classes}">\s*Lesson 1\s*</a>\s*'
r'<a href="/container/{unit}" class="{classes}">\s*Unit\s*</a>\s*'
r'<a href="/container/{split_test}" class="{classes}">\s*Split Test\s*</a>'
).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'),
),
)
......
......@@ -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
......@@ -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")
......
......@@ -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))
......@@ -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",
......
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($("<script>", {id: "section-name-edit-tpl", type: "text/template"}).text(tpl))
appendSetFixtures($("<script>", {id: "system-feedback-tpl", type: "text/template"}).text(feedback_tpl))
spyOn(SectionEdit.prototype, "switchToShowView")
.andCallThrough()
spyOn(SectionEdit.prototype, "showInvalidMessage")
.andCallThrough()
window.analytics = jasmine.createSpyObj('analytics', ['track'])
window.course_location_analytics = jasmine.createSpy()
@model = new Section({
id: 42
name: "Life, the Universe, and Everything"
})
@view = new SectionEdit({model: @model})
@view.render()
afterEach ->
delete window.analytics
delete window.course_location_analytics
it "should have the model name as the default text value", ->
expect(@view.$("input[type=text]").val()).toEqual(@model.get('name'))
it "should call switchToShowView when cancel button is clicked", ->
@view.$("input.cancel-button").click()
expect(@view.switchToShowView).toHaveBeenCalled()
it "should save model when save button is clicked", ->
spyOn(@model, 'save')
@view.$("input[type=submit]").click()
expect(@model.save).toHaveBeenCalled()
it "should call switchToShowView when save() is successful", ->
requests = create_sinon["requests"](this)
@view.$("input[type=submit]").click()
requests[0].respond(200)
expect(@view.switchToShowView).toHaveBeenCalled()
it "should call showInvalidMessage when validation is unsuccessful", ->
spyOn(@model, 'validate').andReturn("BLARRGH")
@view.$("input[type=submit]").click()
expect(@view.showInvalidMessage).toHaveBeenCalledWith(
jasmine.any(Object), "BLARRGH", jasmine.any(Object))
expect(@view.switchToShowView).not.toHaveBeenCalled()
it "should not save when validation is unsuccessful", ->
spyOn(@model, 'validate').andReturn("BLARRGH")
@view.$("input[type=text]").val("changed")
@view.$("input[type=submit]").click()
expect(@model.get('name')).not.toEqual("changed")
define(["backbone", "js/utils/module"], function(Backbone, ModuleUtils) {
define(["backbone", "underscore", "js/utils/module"], function(Backbone, _, ModuleUtils) {
var XBlockInfo = Backbone.Model.extend({
urlRoot: ModuleUtils.urlRoot,
// NOTE: 'publish' is not an attribute on XBlockInfo, but it is used to signal the publish
// and discard changes actions. Therefore 'publish' cannot be introduced as an attribute.
defaults: {
"id": null,
"display_name": null,
"category": null,
"is_container": null,
"data": null,
"metadata" : null,
"children": null,
/**
* The Studio URL for this xblock, or null if it doesn't have one.
*/
"studio_url": null,
/**
* An optional object with information about the children as well as about
* the primary xblock type that is supported as a child.
*/
"child_info": null,
/**
* An optional object with information about each of the ancestors.
*/
"ancestor_info": null,
/**
* True iff:
* 1) Edits have been made to the xblock and no published version exists.
......@@ -56,10 +69,32 @@ define(["backbone", "js/utils/module"], function(Backbone, ModuleUtils) {
* this will either be the parent subsection or the grandparent section.
*/
"release_date_from":null
},
parse: function(response) {
if (response.ancestor_info) {
response.ancestor_info.ancestors = this.parseXBlockInfoList(response.ancestor_info.ancestors);
}
// NOTE: 'publish' is not an attribute on XBlockInfo, but it used to signal the publish
// and discard changes actions. Therefore 'publish' cannot be introduced as an attribute.
if (response.child_info) {
response.child_info.children = this.parseXBlockInfoList(response.child_info.children);
}
return response;
},
parseXBlockInfoList: function(list) {
return _.map(list, function(item) {
return this.createChild(item);
}, this);
},
createChild: function(response) {
return new XBlockInfo(response, { parse: true });
},
hasChildren: function() {
var childInfo = this.get('child_info');
return childInfo && childInfo.children.length > 0;
}
});
return XBlockInfo;
});
define(["js/models/xblock_info"],
function(XBlockInfo) {
var XBlockOutlineInfo = XBlockInfo.extend({
urlRoots: {
'read': '/xblock/outline'
},
createChild: function(response) {
return new XBlockOutlineInfo(response, { parse: true });
},
sync: function(method, model, options) {
var urlRoot = this.urlRoots[method];
if (!urlRoot) {
urlRoot = this.urlRoot;
}
options.url = urlRoot + '/' + this.get('id');
return XBlockInfo.prototype.sync.call(this, method, model, options);
}
});
return XBlockOutlineInfo;
});
......@@ -152,7 +152,7 @@ define(["js/utils/drag_and_drop", "js/views/feedback_notification", "js/spec_hel
});
describe("onDragMove", function () {
beforeEach(function () {
this.scrollSpy = spyOn(window, 'scrollBy').andCallThrough();
this.redirectSpy = spyOn(window, 'scrollBy').andCallThrough();
});
it("adds the correct CSS class to the drop destination", function () {
var $ele, dragX, dragY;
......@@ -199,7 +199,7 @@ define(["js/utils/drag_and_drop", "js/views/feedback_notification", "js/spec_hel
}, '', {
clientY: 2
});
expect(this.scrollSpy).toHaveBeenCalledWith(0, -10);
expect(this.redirectSpy).toHaveBeenCalledWith(0, -10);
});
it("scrolls down if necessary", function () {
ContentDragger.onDragMove({
......@@ -207,7 +207,7 @@ define(["js/utils/drag_and_drop", "js/views/feedback_notification", "js/spec_hel
}, '', {
clientY: window.innerHeight - 5
});
expect(this.scrollSpy).toHaveBeenCalledWith(0, 10);
expect(this.redirectSpy).toHaveBeenCalledWith(0, 10);
});
});
describe("onDragEnd", function () {
......
define([ "jquery", "js/spec_helpers/create_sinon", "js/views/asset", "js/views/assets",
"js/models/asset", "js/collections/asset" ],
function ($, create_sinon, AssetView, AssetsView, AssetModel, AssetCollection) {
"js/models/asset", "js/collections/asset", "js/spec_helpers/view_helpers" ],
function ($, create_sinon, AssetView, AssetsView, AssetModel, AssetCollection, view_helpers) {
describe("Assets", function() {
var assetsView, mockEmptyAssetsResponse, mockAssetUploadResponse,
......@@ -71,13 +71,11 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/views/asset", "js/views/a
};
beforeEach(function () {
window.analytics = jasmine.createSpyObj('analytics', ['track']);
window.course_location_analytics = jasmine.createSpy();
view_helpers.installMockAnalytics();
});
afterEach(function () {
delete window.analytics;
delete window.course_location_analytics;
view_helpers.removeMockAnalytics();
});
it('shows the upload modal when clicked on "Upload your first asset" button', function () {
......
......@@ -77,49 +77,5 @@ define(["jquery", "underscore", "js/views/baseview", "js/utils/handle_iframe_bin
expect(view.$('.is-collapsible')).not.toHaveClass('collapsed');
});
});
describe("disabled element while running", function() {
it("adds 'is-disabled' class to element while action is running and removes it after", function() {
var link,
deferred = new $.Deferred(),
promise = deferred.promise(),
view = new BaseView();
setFixtures("<a href='#' id='link'>ripe apples drop about my head</a>");
link = $("#link");
expect(link).not.toHaveClass("is-disabled");
view.disableElementWhileRunning(link, function() { return promise; });
expect(link).toHaveClass("is-disabled");
deferred.resolve();
expect(link).not.toHaveClass("is-disabled");
});
});
describe("progress notification", function() {
it("shows progress notification and removes it upon success", function() {
var testMessage = "Testing...",
deferred = new $.Deferred(),
promise = deferred.promise(),
view = new BaseView(),
notificationSpy = view_helpers.createNotificationSpy();
view.runOperationShowingMessage(testMessage, function() { return promise; });
view_helpers.verifyNotificationShowing(notificationSpy, /Testing/);
deferred.resolve();
view_helpers.verifyNotificationHidden(notificationSpy);
});
it("shows progress notification and leaves it showing upon failure", function() {
var testMessage = "Testing...",
deferred = new $.Deferred(),
promise = deferred.promise(),
view = new BaseView(),
notificationSpy = view_helpers.createNotificationSpy();
view.runOperationShowingMessage(testMessage, function() { return promise; });
view_helpers.verifyNotificationShowing(notificationSpy, /Testing/);
deferred.fail();
view_helpers.verifyNotificationShowing(notificationSpy, /Testing/);
});
});
});
});
......@@ -13,6 +13,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
edit_helpers.installTemplate('xblock-string-field-editor');
edit_helpers.installTemplate('publish-xblock');
edit_helpers.installTemplate('publish-history');
edit_helpers.installTemplate('unit-outline');
appendSetFixtures(mockContainerPage);
model = new XBlockInfo({
......@@ -21,6 +22,8 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
category: 'vertical',
published: false,
has_changes: false
}, {
parse: true
});
containerPage = new ContainerPage({
model: model,
......@@ -91,39 +94,6 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
});
});
describe("VisibilityStateController", function () {
var unitVisibilityCss = '.section-item.editing a';
it('renders initially as private with unpublished content', function () {
renderContainerPage(mockContainerXBlockHtml, this);
expect(containerPage.$(unitVisibilityCss)).toHaveClass('private-item');
});
it('renders as public when published and no changes', function () {
renderContainerPage(mockContainerXBlockHtml, this);
fetch({"id": "locator-container", "published": true, "has_changes": false});
expect(containerPage.$(unitVisibilityCss)).toHaveClass('public-item');
});
it('renders as draft when published and changes', function () {
renderContainerPage(mockContainerXBlockHtml, this);
fetch({"id": "locator-container", "published": true, "has_changes": true});
expect(containerPage.$(unitVisibilityCss)).toHaveClass('draft-item');
});
it('renders as private when not published', function () {
renderContainerPage(mockContainerXBlockHtml, this);
fetch({"id": "locator-container", "published": false, "has_changes": true});
expect(containerPage.$(unitVisibilityCss)).toHaveClass('private-item');
fetch({"id": "locator-container", "published": false, "has_changes": false});
expect(containerPage.$(unitVisibilityCss)).toHaveClass('private-item');
fetch({"id": "locator-container", "published": false});
expect(containerPage.$(unitVisibilityCss)).toHaveClass('private-item');
});
});
describe("Publisher", function () {
var headerCss = '.pub-status',
bitPublishingCss = "div.bit-publishing",
......@@ -132,7 +102,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
publishButtonCss = ".action-publish",
discardChangesButtonCss = ".action-discard",
lastDraftCss = ".wrapper-last-draft",
request, lastRequest, promptSpies, sendDiscardChangesToServer;
lastRequest, promptSpies, sendDiscardChangesToServer;
lastRequest = function() { return requests[requests.length - 1]; };
......
define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers", "js/views/utils/view_utils",
"js/views/unit_outline", "js/models/xblock_info"],
function ($, create_sinon, view_helpers, ViewUtils, UnitOutlineView, XBlockInfo) {
describe("UnitOutlineView", function() {
var createUnitOutlineView, createMockXBlockInfo,
requests, model, unitOutlineView;
createUnitOutlineView = function(test, unitJSON, createOnly) {
requests = create_sinon.requests(test);
model = new XBlockInfo(unitJSON, { parse: true });
unitOutlineView = new UnitOutlineView({
model: model,
el: $('.wrapper-unit-overview')
});
if (!createOnly) {
unitOutlineView.render();
}
return unitOutlineView;
};
createMockXBlockInfo = function(displayName) {
return {
id: 'mock-unit',
category: 'vertical',
display_name: displayName,
studio_url: '/container/mock-unit',
ancestor_info: {
ancestors: [{
id: 'mock-subsection',
category: 'sequential',
display_name: 'Mock Subsection',
studio_url: '/course/mock-course?show=mock-subsection',
child_info: {
category: 'vertical',
display_name: 'Unit',
children: [{
id: 'mock-unit',
category: 'vertical',
display_name: displayName,
studio_url: '/container/mock-unit'
}, {
id: 'mock-unit-2',
category: 'vertical',
display_name: 'Mock Unit 2',
studio_url: '/container/mock-unit-2'
}]
}
}, {
id: 'mock-section',
category: 'chapter',
display_name: 'Section',
studio_url: '/course/slashes:mock-course?show=mock-section'
}, {
id: 'mock-course',
category: 'course',
display_name: 'Mock Course',
studio_url: '/course/mock-course'
}]
},
metadata: {
display_name: 'Mock Unit'
}
};
};
beforeEach(function () {
view_helpers.installMockAnalytics();
view_helpers.installViewTemplates();
view_helpers.installTemplate('unit-outline');
appendSetFixtures('<div class="wrapper-unit-overview"></div>');
});
afterEach(function () {
view_helpers.removeMockAnalytics();
});
it('can render itself', function() {
createUnitOutlineView(this, createMockXBlockInfo('Mock Unit'));
expect(unitOutlineView.$('.sortable-course-list')).toExist();
expect(unitOutlineView.$('.sortable-section-list')).toExist();
expect(unitOutlineView.$('.sortable-subsection-list')).toExist();
});
it('can add a unit', function() {
var redirectSpy;
createUnitOutlineView(this, createMockXBlockInfo('Mock Unit'));
redirectSpy = spyOn(ViewUtils, 'redirect');
unitOutlineView.$('.outline-item-subsection > .add-xblock-component .add-button').click();
create_sinon.expectJsonRequest(requests, 'POST', '/xblock/', {
category: 'vertical',
display_name: 'Unit',
parent_locator: 'mock-subsection'
});
create_sinon.respondWithJson(requests, {
locator: "new-mock-unit",
courseKey: "slashes:MockCourse"
});
expect(redirectSpy).toHaveBeenCalledWith('/container/new-mock-unit?action=new');
});
it('refreshes when the XBlockInfo model syncs', function() {
var updatedDisplayName = 'Mock Unit Updated', unitHeader;
createUnitOutlineView(this, createMockXBlockInfo('Mock Unit'));
unitOutlineView.refresh();
create_sinon.expectJsonRequest(requests, 'GET', '/xblock/mock-unit');
create_sinon.respondWithJson(requests,
createMockXBlockInfo(updatedDisplayName));
unitHeader = unitOutlineView.$('.outline-item-unit .wrapper-xblock-header');
expect(unitHeader.find('.xblock-title').first().text().trim()).toBe(updatedDisplayName);
});
});
});
define(["jquery", "underscore", "js/views/baseview", "js/views/utils/view_utils", "js/spec_helpers/edit_helpers"],
function ($, _, BaseView, ViewUtils, view_helpers) {
describe("ViewUtils", function() {
describe("disabled element while running", function() {
it("adds 'is-disabled' class to element while action is running and removes it after", function() {
var link,
deferred = new $.Deferred(),
promise = deferred.promise();
setFixtures("<a href='#' id='link'>ripe apples drop about my head</a>");
link = $("#link");
expect(link).not.toHaveClass("is-disabled");
ViewUtils.disableElementWhileRunning(link, function() { return promise; });
expect(link).toHaveClass("is-disabled");
deferred.resolve();
expect(link).not.toHaveClass("is-disabled");
});
});
describe("progress notification", function() {
it("shows progress notification and removes it upon success", function() {
var testMessage = "Testing...",
deferred = new $.Deferred(),
promise = deferred.promise(),
notificationSpy = view_helpers.createNotificationSpy();
ViewUtils.runOperationShowingMessage(testMessage, function() { return promise; });
view_helpers.verifyNotificationShowing(notificationSpy, /Testing/);
deferred.resolve();
view_helpers.verifyNotificationHidden(notificationSpy);
});
it("shows progress notification and leaves it showing upon failure", function() {
var testMessage = "Testing...",
deferred = new $.Deferred(),
promise = deferred.promise(),
notificationSpy = view_helpers.createNotificationSpy();
ViewUtils.runOperationShowingMessage(testMessage, function() { return promise; });
view_helpers.verifyNotificationShowing(notificationSpy, /Testing/);
deferred.fail();
view_helpers.verifyNotificationShowing(notificationSpy, /Testing/);
});
});
});
});
......@@ -3,13 +3,8 @@
*/
define(["jquery", "js/spec_helpers/view_helpers"],
function($, view_helpers) {
var installModalTemplates,
getModalElement,
isShowingModal,
hideModalIfShowing,
pressModalButton,
cancelModal,
cancelModalIfShowing;
var installModalTemplates, getModalElement, getModalTitle, isShowingModal, hideModalIfShowing,
pressModalButton, cancelModal, cancelModalIfShowing;
installModalTemplates = function(append) {
view_helpers.installViewTemplates(append);
......
/**
* Provides helper methods for invoking Studio modal windows in Jasmine tests.
*/
define(['jquery', 'js/views/feedback_notification', 'js/views/feedback_prompt'],
define(["jquery", "js/views/feedback_notification", "js/views/feedback_prompt"],
function($, NotificationView, Prompt) {
'use strict';
var installTemplate, installTemplates, installViewTemplates, createFeedbackSpy, verifyFeedbackShowing,
verifyFeedbackHidden, createNotificationSpy, verifyNotificationShowing,
verifyNotificationHidden, createPromptSpy, confirmPrompt, verifyPromptShowing,
verifyPromptHidden;
verifyNotificationHidden, createPromptSpy, confirmPrompt, inlineEdit, verifyInlineEditChange,
installMockAnalytics, removeMockAnalytics, verifyPromptShowing, verifyPromptHidden;
installTemplate = function(templateName, isFirst, templateId) {
var template = readFixtures(templateName + '.underscore');
......@@ -92,6 +91,38 @@ define(['jquery', 'js/views/feedback_notification', 'js/views/feedback_prompt'],
verifyFeedbackHidden.apply(this, arguments);
};
installMockAnalytics = function() {
window.analytics = jasmine.createSpyObj('analytics', ['track']);
window.course_location_analytics = jasmine.createSpy();
};
removeMockAnalytics = function() {
delete window.analytics;
delete window.course_location_analytics;
};
inlineEdit = function(element, newValue) {
var inputField;
element.click();
expect(element).toHaveClass('is-hidden');
inputField = element.next().find('.xblock-field-input');
expect(inputField).not.toHaveClass('is-hidden');
inputField.val(newValue);
return inputField;
};
verifyInlineEditChange = function(element, expectedValue, failedValue) {
var inputField = element.next().find('.xblock-field-input');
expect(element.text()).toBe(expectedValue);
if (failedValue) {
expect(element).toHaveClass('is-hidden');
expect(inputField).not.toHaveClass('is-hidden');
} else {
expect(element).not.toHaveClass('is-hidden');
expect(inputField).toHaveClass('is-hidden');
}
};
return {
'installTemplate': installTemplate,
'installTemplates': installTemplates,
......@@ -102,6 +133,10 @@ define(['jquery', 'js/views/feedback_notification', 'js/views/feedback_prompt'],
'confirmPrompt': confirmPrompt,
'createPromptSpy': createPromptSpy,
'verifyPromptShowing': verifyPromptShowing,
'verifyPromptHidden': verifyPromptHidden
'verifyPromptHidden': verifyPromptHidden,
'inlineEdit': inlineEdit,
'verifyInlineEditChange': verifyInlineEditChange,
'installMockAnalytics': installMockAnalytics,
'removeMockAnalytics': removeMockAnalytics
};
});
define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging", "js/views/asset",
"js/views/paging_header", "js/views/paging_footer", "js/utils/modal"],
function($, _, gettext, AssetModel, PagingView, AssetView, PagingHeader, PagingFooter, ModalUtils) {
"js/views/paging_header", "js/views/paging_footer", "js/utils/modal", "js/views/utils/view_utils"],
function($, _, gettext, AssetModel, PagingView, AssetView, PagingHeader, PagingFooter, ModalUtils, ViewUtils) {
var AssetsView = PagingView.extend({
// takes AssetCollection as model
......@@ -18,7 +18,7 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging",
this.registerSortableColumn('js-asset-name-col', gettext('Name'), 'display_name', 'asc');
this.registerSortableColumn('js-asset-date-col', gettext('Date Added'), 'date_added', 'desc');
this.setInitialSortColumn('js-asset-date-col');
this.showLoadingIndicator();
ViewUtils.showLoadingIndicator();
this.setPage(0);
assetsView = this;
},
......@@ -39,7 +39,7 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging",
getTableBody: function() {
var tableBody = this.tableBody;
if (!tableBody) {
this.hideLoadingIndicator();
ViewUtils.hideLoadingIndicator();
// Create the table
this.$el.html(this.template());
......@@ -77,7 +77,7 @@ define(["jquery", "underscore", "gettext", "js/models/asset", "js/views/paging",
},
onError: function() {
this.hideLoadingIndicator();
ViewUtils.hideLoadingIndicator();
},
handleDestroy: function(model) {
......
define(["jquery", "underscore", "backbone", "gettext", "js/utils/handle_iframe_binding", "js/utils/templates",
"js/views/feedback_notification", "js/views/feedback_prompt"],
function ($, _, Backbone, gettext, IframeUtils, TemplateUtils, NotificationView, PromptView) {
"js/views/utils/view_utils"],
function ($, _, Backbone, gettext, IframeUtils, TemplateUtils, ViewUtils) {
/*
This view is extended from backbone to provide useful functionality for all Studio views.
This functionality includes:
......@@ -48,75 +48,7 @@ define(["jquery", "underscore", "backbone", "gettext", "js/utils/handle_iframe_b
// this element, e.g. clicking on the element of a child view container in a parent.
event.stopPropagation();
event.preventDefault();
target.closest('.expand-collapse').toggleClass('expand').toggleClass('collapse');
target.closest('.is-collapsible, .window').toggleClass('collapsed');
target.closest('.is-collapsible').children('article').slideToggle();
},
showLoadingIndicator: function() {
$('.ui-loading').show();
},
hideLoadingIndicator: function() {
$('.ui-loading').hide();
},
/**
* Confirms with the user whether to run an operation or not, and then runs it if desired.
*/
confirmThenRunOperation: function(title, message, actionLabel, operation) {
var self = this;
return new PromptView.Warning({
title: title,
message: message,
actions: {
primary: {
text: actionLabel,
click: function(prompt) {
prompt.hide();
operation();
}
},
secondary: {
text: gettext('Cancel'),
click: function(prompt) {
return prompt.hide();
}
}
}
}).show();
},
/**
* Shows a progress message for the duration of an asynchronous operation.
* Note: this does not remove the notification upon failure because an error
* will be shown that shouldn't be removed.
* @param message The message to show.
* @param operation A function that returns a promise representing the operation.
*/
runOperationShowingMessage: function(message, operation) {
var notificationView;
notificationView = new NotificationView.Mini({
title: gettext(message)
});
notificationView.show();
return operation().done(function() {
notificationView.hide();
});
},
/**
* Disables a given element when a given operation is running.
* @param {jQuery} element: the element to be disabled.
* @param operation: the operation during whose duration the
* element should be disabled. The operation should return
* a JQuery promise.
*/
disableElementWhileRunning: function(element, operation) {
element.addClass("is-disabled");
return operation().always(function() {
element.removeClass("is-disabled");
});
ViewUtils.toggleExpandCollapse(target);
},
/**
......@@ -126,37 +58,6 @@ define(["jquery", "underscore", "backbone", "gettext", "js/utils/handle_iframe_b
*/
loadTemplate: function(name) {
return TemplateUtils.loadTemplate(name);
},
/**
* Returns the relative position that the element is scrolled from the top of the view port.
* @param element The element in question.
*/
getScrollOffset: function(element) {
var elementTop = element.offset().top;
return elementTop - $(window).scrollTop();
},
/**
* Scrolls the window so that the element is scrolled down to the specified relative position
* from the top of the view port.
* @param element The element in question.
* @param offset The amount by which the element should be scrolled from the top of the view port.
*/
setScrollOffset: function(element, offset) {
var elementTop = element.offset().top,
newScrollTop = elementTop - offset;
this.setScrollTop(newScrollTop);
},
/**
* Performs an animated scroll so that the window has the specified scroll top.
* @param scrollTop The desired scroll top for the window.
*/
setScrollTop: function(scrollTop) {
$('html, body').animate({
scrollTop: scrollTop
}, 500);
}
});
......
/**
* This is a simple component that renders add buttons for all available XBlock template types.
*/
define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/components/add_xblock_button",
"js/views/components/add_xblock_menu"],
function ($, _, gettext, BaseView, AddXBlockButton, AddXBlockMenu) {
define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/view_utils",
"js/views/components/add_xblock_button", "js/views/components/add_xblock_menu"],
function ($, _, gettext, BaseView, ViewUtils, AddXBlockButton, AddXBlockMenu) {
var AddXBlockComponent = BaseView.extend({
events: {
'click .new-component .new-component-type a.multiple-templates': 'showComponentTemplates',
......@@ -56,16 +56,16 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/compon
var self = this,
element = $(event.currentTarget),
saveData = element.data(),
oldOffset = this.getScrollOffset(this.$el);
oldOffset = ViewUtils.getScrollOffset(this.$el);
event.preventDefault();
this.closeNewComponent(event);
this.runOperationShowingMessage(
ViewUtils.runOperationShowingMessage(
gettext('Adding&hellip;'),
_.bind(this.options.createComponent, this, saveData, element)
).always(function() {
// Restore the scroll position of the buttons so that the new
// component appears above them.
self.setScrollOffset(self.$el, oldOffset);
ViewUtils.setScrollOffset(self.$el, oldOffset);
});
}
});
......
/**
* The CourseOutlineView is used to render the contents of the course for the Course Outline page.
* It is a recursive set of views, where each XBlock has its own instance, and each of the children
* are shown as child CourseOutlineViews.
*
* This class extends XBlockOutlineView to add unique capabilities needed by the course outline:
* - sections are initially expanded but subsections and other children are shown as collapsed
* - changes cause a refresh of the entire section rather than just the view for the changed xblock
* - adding units will automatically redirect to the unit page rather than showing them inline
*/
define(["jquery", "underscore", "js/views/xblock_outline", "js/views/utils/view_utils",
"js/models/xblock_outline_info"],
function($, _, XBlockOutlineView, ViewUtils, XBlockOutlineInfo) {
var CourseOutlineView = XBlockOutlineView.extend({
// takes XBlockOutlineInfo as a model
templateName: 'course-outline',
shouldExpandChildren: function() {
// Expand the children if this xblock's locator is in the initially expanded state
if (this.initialState && _.contains(this.initialState.expanded_locators, this.model.id)) {
return true;
}
// Only expand the course and its chapters (aka sections) initially
var category = this.model.get('category');
return category === 'course' || category === 'chapter';
},
shouldRenderChildren: function() {
// Render all nodes up to verticals but not below
return this.model.get('category') !== 'vertical';
},
createChildView: function(xblockInfo, parentInfo, parentView) {
return new CourseOutlineView({
model: xblockInfo,
parentInfo: parentInfo,
initialState: this.initialState,
template: this.template,
parentView: parentView || this
});
},
getExpandedLocators: function() {
var expandedLocators = [];
this.$('.outline-item.is-collapsible').each(function(index, rawElement) {
var element = $(rawElement);
if (!element.hasClass('collapsed')) {
expandedLocators.push(element.data('locator'));
}
});
return expandedLocators;
},
/**
* Refresh the containing section (if there is one) or else refresh the entire course.
* Note that the refresh will preserve the expanded state of this view and all of its
* children.
* @param viewState The desired initial state of the view, or null if none.
* @returns {jQuery promise} A promise representing the refresh operation.
*/
refresh: function(viewState) {
var getViewToRefresh, view, expandedLocators;
getViewToRefresh = function(view) {
if (view.model.get('category') === 'chapter' || !view.parentView) {
return view;
}
return getViewToRefresh(view.parentView);
};
view = getViewToRefresh(this);
expandedLocators = view.getExpandedLocators();
viewState = viewState || {};
viewState.expanded_locators = expandedLocators.concat(viewState.expanded_locators || []);
view.initialState = viewState;
return view.model.fetch({});
},
onChildAdded: function(locator, category, event) {
if (category === 'vertical') {
// For units, redirect to the new unit's page in inline edit mode
this.onUnitAdded(locator);
} else if (category === 'chapter' && this.model.hasChildren()) {
this.onSectionAdded(locator);
} else {
// For all other block types, refresh the view and do the following:
// - show the new block expanded
// - ensure it is scrolled into view
// - make its name editable
this.refresh(this.createNewItemViewState(locator, ViewUtils.getScrollOffset($(event.target))));
}
},
onSectionAdded: function(locator) {
var self = this,
initialState = self.createNewItemViewState(locator),
sectionInfo, sectionView;
// For new chapters in a non-empty view, add a new child view and render it
// to avoid the expense of refreshing the entire page.
if (this.model.hasChildren()) {
sectionInfo = new XBlockOutlineInfo({
id: locator,
category: 'chapter'
});
// Fetch the full xblock info for the section and then create a view for it
sectionInfo.fetch().done(function() {
sectionView = self.createChildView(sectionInfo, self.model, self);
sectionView.initialState = initialState;
sectionView.render();
self.addChildView(sectionView);
sectionView.setViewState(initialState);
});
} else {
this.refresh(initialState);
}
},
createNewItemViewState: function(locator, scrollOffset) {
return {
locator_to_show: locator,
edit_display_name: true,
expanded_locators: [ locator ],
scroll_offset: scrollOffset || 0
};
}
});
return CourseOutlineView;
}); // end define();
......@@ -3,9 +3,9 @@
* It is invoked using the edit method which is passed an existing rendered xblock,
* and upon save an optional refresh function can be invoked to update the display.
*/
define(["jquery", "underscore", "gettext", "js/views/modals/base_modal",
define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/views/utils/view_utils",
"js/models/xblock_info", "js/views/xblock_editor"],
function($, _, gettext, BaseModal, XBlockInfo, XBlockEditorView) {
function($, _, gettext, BaseModal, ViewUtils, XBlockInfo, XBlockEditorView) {
var EditXBlockModal = BaseModal.extend({
events : {
"click .action-save": "save",
......@@ -153,7 +153,7 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal",
data = editorView.getXModuleData();
event.preventDefault();
if (data) {
this.runOperationShowingMessage(gettext('Saving&hellip;'),
ViewUtils.runOperationShowingMessage(gettext('Saving&hellip;'),
function() {
return xblockInfo.save(data);
}).done(function() {
......
......@@ -31,7 +31,7 @@ define(["domReady", "jquery", "jquery.ui", "underscore", "gettext", "js/views/fe
var toggleSubmodules = function(e) {
e.preventDefault();
$(this).toggleClass('expand').toggleClass('collapse');
$(this).toggleClass('expand collapse');
$(this).closest('.is-collapsible, .window').toggleClass('collapsed');
};
......
/**
* This is the base view that all Studio pages extend from.
*/
define(['jquery', 'js/views/baseview'],
function ($, BaseView) {
var BasePage = BaseView.extend({
initialize: function() {
BaseView.prototype.initialize.call(this);
},
/**
* Returns true if this page is currently showing any content. If this returns false
* then the page will unhide the div with the class 'no-content'.
*/
hasContent: function() {
return true;
},
/**
* This renders the page's content and returns a promise that will be resolved once
* the rendering has completed.
* @returns {jQuery promise} A promise representing the rendering of the page.
*/
renderPage: function() {
return $.Deferred().resolve().promise();
},
/**
* Renders the current page while showing a loading indicator. Note that subclasses
* of BasePage should implement renderPage to perform the rendering of the content.
* If the page has no content (i.e. it returns false for hasContent) then the
* div with the class 'no-content' will be shown.
*/
render: function() {
var self = this;
this.$('.ui-loading').removeClass('is-hidden');
this.renderPage().done(function() {
if (!self.hasContent()) {
self.$('.no-content').removeClass('is-hidden');
}
}).always(function() {
self.$('.ui-loading').addClass('is-hidden');
});
return this;
}
});
return BasePage;
}); // end define();
......@@ -2,23 +2,29 @@
* XBlockContainerPage is used to display Studio's container page for an xblock which has children.
* This page allows the user to understand and manipulate the xblock and its children.
*/
define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/container",
"js/views/xblock", "js/views/components/add_xblock", "js/views/modals/edit_xblock", "js/models/xblock_info",
"js/views/xblock_string_field_editor", "js/views/pages/container_subviews"],
function ($, _, gettext, BaseView, ContainerView, XBlockView, AddXBlockComponent, EditXBlockModal, XBlockInfo,
XBlockStringFieldEditor, ContainerSubviews) {
var XBlockContainerPage = BaseView.extend({
define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views/utils/view_utils",
"js/views/container", "js/views/xblock", "js/views/components/add_xblock", "js/views/modals/edit_xblock",
"js/models/xblock_info", "js/views/xblock_string_field_editor", "js/views/pages/container_subviews",
"js/views/unit_outline", "js/views/utils/xblock_utils"],
function ($, _, gettext, BasePage, ViewUtils, ContainerView, XBlockView, AddXBlockComponent,
EditXBlockModal, XBlockInfo, XBlockStringFieldEditor, ContainerSubviews, UnitOutlineView,
XBlockUtils) {
var XBlockContainerPage = BasePage.extend({
// takes XBlockInfo as a model
view: 'container_preview',
initialize: function(options) {
BaseView.prototype.initialize.call(this);
BasePage.prototype.initialize.call(this, options);
this.nameEditor = new XBlockStringFieldEditor({
el: this.$('.wrapper-xblock-field'),
model: this.model
});
this.nameEditor.render();
if (this.options.action === 'new') {
this.nameEditor.$('.xblock-field-value').click();
}
this.model.on('sync', this.onSync, this);
this.xblockView = new ContainerView({
el: this.$('.wrapper-xblock'),
model: this.model,
......@@ -40,17 +46,23 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/contai
});
this.publishHistory.render();
// No need to render initially. This is only used for updating state
// when the unit changes visibility.
this.visibilityState = new ContainerSubviews.VisibilityStateController({
el: this.$('.section-item.editing a'),
model: this.model
});
this.previewActions = new ContainerSubviews.PreviewActionController({
el: this.$('.nav-actions'),
model: this.model
});
this.previewActions.render();
this.unitOutlineView = new UnitOutlineView({
el: this.$('.wrapper-unit-overview'),
model: this.model
});
this.unitOutlineView.render();
}
},
onSync: function(model) {
if (ViewUtils.hasChangedAttributes(model, ['display_name'])) {
this.render();
}
},
......@@ -153,7 +165,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/contai
parentLocator = parentElement.data('locator'),
buttonPanel = target.closest('.add-xblock-component'),
listPanel = buttonPanel.prev(),
scrollOffset = this.getScrollOffset(buttonPanel),
scrollOffset = ViewUtils.getScrollOffset(buttonPanel),
placeholderElement = $('<div class="studio-xblock-wrapper"></div>').appendTo(listPanel),
requestData = _.extend(template, {
parent_locator: parentLocator
......@@ -172,9 +184,9 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/contai
// for xblocks that can't be replaced inline, the entire parent will be refreshed.
var self = this,
parent = xblockElement.parent();
this.runOperationShowingMessage(gettext('Duplicating&hellip;'),
ViewUtils.runOperationShowingMessage(gettext('Duplicating&hellip;'),
function() {
var scrollOffset = self.getScrollOffset(xblockElement),
var scrollOffset = ViewUtils.getScrollOffset(xblockElement),
placeholderElement = $('<div class="studio-xblock-wrapper"></div>').insertAfter(xblockElement),
parentElement = self.findXBlockElement(parent),
requestData = {
......@@ -191,19 +203,12 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/contai
},
deleteComponent: function(xblockElement) {
var self = this;
this.confirmThenRunOperation(gettext('Delete this component?'),
gettext('Deleting this component is permanent and cannot be undone.'),
gettext('Yes, delete this component'),
function() {
self.runOperationShowingMessage(gettext('Deleting&hellip;'),
function() {
return $.ajax({
type: 'DELETE',
url: self.getURLRoot() + "/" +
xblockElement.data('locator')
}).success(_.bind(self.onDelete, self, xblockElement));
var self = this,
xblockInfo = new XBlockInfo({
id: xblockElement.data('locator')
});
XBlockUtils.deleteXBlock(xblockInfo).done(function() {
self.onDelete(xblockElement);
});
},
......@@ -219,7 +224,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/contai
},
onNewXBlock: function(xblockElement, scrollOffset, data) {
this.setScrollOffset(xblockElement, scrollOffset);
ViewUtils.setScrollOffset(xblockElement, scrollOffset);
xblockElement.data('locator', data.locator);
return this.refreshXBlock(xblockElement);
},
......@@ -247,7 +252,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/contai
* Refresh an xblock element inline on the page, using the specified xblockInfo.
* Note that the element is removed and replaced with the newly rendered xblock.
* @param xblockElement The xblock element to be refreshed.
* @returns {promise} A promise representing the complete operation.
* @returns {jQuery promise} A promise representing the complete operation.
*/
refreshChildXBlock: function(xblockElement) {
var self = this,
......
/**
* Subviews (usually small side panels) for XBlockContainerPage.
*/
define(["jquery", "underscore", "gettext", "js/views/baseview"],
function ($, _, gettext, BaseView) {
define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/view_utils"],
function ($, _, gettext, BaseView, ViewUtils) {
var disabledCss = "is-disabled";
......@@ -17,9 +17,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview"],
this.model.on('sync', this.onSync, this);
},
onSync: function(e) {
if (e.changedAttributes() &&
(('has_changes' in e.changedAttributes()) || ('published' in e.changedAttributes()))) {
onSync: function(model) {
if (ViewUtils.hasChangedAttributes(model, ['has_changes', 'published'])) {
this.render();
}
},
......@@ -28,29 +27,6 @@ define(["jquery", "underscore", "gettext", "js/views/baseview"],
});
/**
* A controller for updating the visibility status of the unit on the RHS navigation tree.
*/
var VisibilityStateController = UnitStateListenerView.extend({
render: function() {
var computeState = function(published, has_changes) {
if (!published) {
return "private";
}
else if (has_changes) {
return "draft";
}
else {
return "public";
}
};
var state = computeState(this.model.get('published'), this.model.get('has_changes'));
this.$el.removeClass("private-item public-item draft-item");
this.$el.addClass(state + "-item");
}
});
/**
* A controller for updating the "View Live" and "Preview" buttons.
*/
var PreviewActionController = UnitStateListenerView.extend({
......@@ -95,10 +71,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview"],
this.renderPage = this.options.renderPage;
},
onSync: function(e) {
if (e.changedAttributes() &&
(('has_changes' in e.changedAttributes()) || ('published' in e.changedAttributes()) ||
('edited_on' in e.changedAttributes()) || ('edited_by' in e.changedAttributes()))) {
onSync: function(model) {
if (ViewUtils.hasChangedAttributes(model, ['has_changes', 'published', 'edited_on', 'edited_by'])) {
this.render();
}
},
......@@ -121,7 +95,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview"],
if (e && e.preventDefault) {
e.preventDefault();
}
this.runOperationShowingMessage(gettext('Publishing&hellip;'),
ViewUtils.runOperationShowingMessage(gettext('Publishing&hellip;'),
function () {
return xblockInfo.save({publish: 'make_public'}, {patch: true});
}).always(function() {
......@@ -136,11 +110,11 @@ define(["jquery", "underscore", "gettext", "js/views/baseview"],
if (e && e.preventDefault) {
e.preventDefault();
}
this.confirmThenRunOperation(gettext("Discard Changes"),
ViewUtils.confirmThenRunOperation(gettext("Discard Changes"),
gettext("Are you sure you want to discard changes and revert to the last published version?"),
gettext("Discard Changes"),
function () {
that.runOperationShowingMessage(gettext('Discarding Changes&hellip;'),
ViewUtils.runOperationShowingMessage(gettext('Discarding Changes&hellip;'),
function () {
return xblockInfo.save({publish: 'discard_changes'}, {patch: true});
}).always(function() {
......@@ -166,9 +140,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview"],
this.model.on('sync', this.onSync, this);
},
onSync: function(e) {
if (e.changedAttributes() && (('published' in e.changedAttributes()) ||
('published_on' in e.changedAttributes()) || ('published_by' in e.changedAttributes()))) {
onSync: function(model) {
if (ViewUtils.hasChangedAttributes(model, ['published', 'published_on', 'published_by'])) {
this.render();
}
},
......@@ -185,7 +158,6 @@ define(["jquery", "underscore", "gettext", "js/views/baseview"],
});
return {
'VisibilityStateController': VisibilityStateController,
'PreviewActionController': PreviewActionController,
'Publisher': Publisher,
'PublishHistory': PublishHistory
......
/**
* This page is used to show the user an outline of the course.
*/
define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views/utils/xblock_utils",
"js/views/course_outline"],
function ($, _, gettext, BasePage, XBlockViewUtils, CourseOutlineView) {
var CourseOutlinePage = BasePage.extend({
// takes XBlockInfo as a model
events: {
"click .toggle-button-expand-collapse": "toggleExpandCollapse"
},
initialize: function() {
var self = this;
this.initialState = this.options.initialState;
BasePage.prototype.initialize.call(this);
this.$('.add-button').click(function(event) {
self.outlineView.handleAddEvent(event);
});
this.model.on('change', this.setCollapseExpandVisibility, this);
},
setCollapseExpandVisibility: function() {
var has_content = this.hasContent(),
collapseExpandButton = $('.toggle-button-expand-collapse');
if (has_content) {
collapseExpandButton.show();
} else {
collapseExpandButton.hide();
}
},
renderPage: function() {
var locatorToShow;
this.setCollapseExpandVisibility();
this.outlineView = new CourseOutlineView({
el: this.$('.course-outline'),
model: this.model,
isRoot: true,
initialState: this.initialState
});
this.outlineView.render();
this.outlineView.setViewState(this.initialState || {});
return $.Deferred().resolve().promise();
},
hasContent: function() {
return this.model.hasChildren();
},
toggleExpandCollapse: function(event) {
var toggleButton = this.$('.toggle-button-expand-collapse'),
collapse = toggleButton.hasClass('collapse-all');
event.preventDefault();
toggleButton.toggleClass('collapse-all expand-all');
this.$('.course-outline > ol > li').each(function(index, domElement) {
var element = $(domElement),
expandCollapseElement = element.find('.expand-collapse').first();
if (collapse) {
expandCollapseElement.removeClass('expand').addClass('collapse');
element.addClass('collapsed');
} else {
expandCollapseElement.addClass('expand').removeClass('collapse');
element.removeClass('collapsed');
}
});
}
});
return CourseOutlinePage;
}); // end define();
......@@ -40,9 +40,7 @@ function ($, _, gettext, BaseView, GroupConfigurationsList) {
});
if(dirty) {
return gettext(
'You have unsaved changes. Do you really want to leave this page?'
);
return gettext('You have unsaved changes. Do you really want to leave this page?');
}
},
......
/**
* The UnitOutlineView is used to render the Unit Outline component on the unit page. It shows
* the ancestors of the unit along with its direct siblings. It also has a single "New Unit"
* button to allow a new sibling unit to be added.
*/
define(['js/views/xblock_outline'],
function(XBlockOutlineView) {
var UnitOutlineView = XBlockOutlineView.extend({
// takes XBlockInfo as a model
templateName: 'unit-outline',
render: function() {
XBlockOutlineView.prototype.render.call(this);
this.renderAncestors();
return this;
},
renderAncestors: function() {
var i, listElement,
ancestors, ancestor, ancestorView = this,
previousAncestor = null;
if (this.model.get('ancestor_info')) {
ancestors = this.model.get('ancestor_info').ancestors;
listElement = this.$('.sortable-list');
// Note: the ancestors are processed in reverse order because the tree wants to
// start at the root, but the ancestors are ordered by closeness to the unit,
// i.e. subsection and then section.
for (i=ancestors.length - 1; i >= 0; i--) {
ancestor = ancestors[i];
ancestorView = this.createChildView(ancestor, previousAncestor, ancestorView);
ancestorView.render();
listElement.append(ancestorView.$el);
previousAncestor = ancestor;
listElement = ancestorView.$('.sortable-list');
}
}
return ancestorView;
}
});
return UnitOutlineView;
}); // end define();
/**
* Provides useful utilities for views.
*/
define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js/views/feedback_prompt"],
function ($, _, gettext, NotificationView, PromptView) {
var toggleExpandCollapse, showLoadingIndicator, hideLoadingIndicator, confirmThenRunOperation,
runOperationShowingMessage, disableElementWhileRunning, getScrollOffset, setScrollOffset,
setScrollTop, redirect, hasChangedAttributes;
/**
* Toggles the expanded state of the current element.
*/
toggleExpandCollapse = function(target) {
target.closest('.expand-collapse').toggleClass('expand collapse');
target.closest('.is-collapsible, .window').toggleClass('collapsed');
target.closest('.is-collapsible').children('article').slideToggle();
};
/**
* Show the page's loading indicator.
*/
showLoadingIndicator = function() {
$('.ui-loading').show();
};
/**
* Hide the page's loading indicator.
*/
hideLoadingIndicator = function() {
$('.ui-loading').hide();
};
/**
* Confirms with the user whether to run an operation or not, and then runs it if desired.
*/
confirmThenRunOperation = function(title, message, actionLabel, operation) {
return new PromptView.Warning({
title: title,
message: message,
actions: {
primary: {
text: actionLabel,
click: function(prompt) {
prompt.hide();
operation();
}
},
secondary: {
text: gettext('Cancel'),
click: function(prompt) {
return prompt.hide();
}
}
}
}).show();
};
/**
* Shows a progress message for the duration of an asynchronous operation.
* Note: this does not remove the notification upon failure because an error
* will be shown that shouldn't be removed.
* @param message The message to show.
* @param operation A function that returns a promise representing the operation.
*/
runOperationShowingMessage = function(message, operation) {
var notificationView;
notificationView = new NotificationView.Mini({
title: gettext(message)
});
notificationView.show();
return operation().done(function() {
notificationView.hide();
});
};
/**
* Disables a given element when a given operation is running.
* @param {jQuery} element the element to be disabled.
* @param operation the operation during whose duration the
* element should be disabled. The operation should return
* a JQuery promise.
*/
disableElementWhileRunning = function(element, operation) {
element.addClass("is-disabled");
return operation().always(function() {
element.removeClass("is-disabled");
});
};
/**
* Performs an animated scroll so that the window has the specified scroll top.
* @param scrollTop The desired scroll top for the window.
*/
setScrollTop = function(scrollTop) {
$('html, body').animate({
scrollTop: scrollTop
}, 500);
};
/**
* Returns the relative position that the element is scrolled from the top of the view port.
* @param element The element in question.
*/
getScrollOffset = function(element) {
var elementTop = element.offset().top;
return elementTop - $(window).scrollTop();
};
/**
* Scrolls the window so that the element is scrolled down to the specified relative position
* from the top of the view port.
* @param element The element in question.
* @param offset The amount by which the element should be scrolled from the top of the view port.
*/
setScrollOffset = function(element, offset) {
var elementTop = element.offset().top,
newScrollTop = elementTop - offset;
setScrollTop(newScrollTop);
};
/**
* Redirects to the specified URL. This is broken out as its own function for unit testing.
*/
redirect = function(url) {
window.location = url;
};
/**
* Returns true if a model has changes to at least one of the specified attributes.
* @param model The model in question.
* @param attributes The list of attributes to be compared.
* @returns {boolean} Returns true if attribute changes are found.
*/
hasChangedAttributes = function(model, attributes) {
var i, changedAttributes = model.changedAttributes();
if (!changedAttributes) {
return false;
}
for (i=0; i < attributes.length; i++) {
if (_.has(changedAttributes, attributes[i])) {
return true;
}
}
return false;
};
return {
'toggleExpandCollapse': toggleExpandCollapse,
'showLoadingIndicator': showLoadingIndicator,
'hideLoadingIndicator': hideLoadingIndicator,
'confirmThenRunOperation': confirmThenRunOperation,
'runOperationShowingMessage': runOperationShowingMessage,
'disableElementWhileRunning': disableElementWhileRunning,
'setScrollTop': setScrollTop,
'getScrollOffset': getScrollOffset,
'setScrollOffset': setScrollOffset,
'redirect': redirect,
'hasChangedAttributes': hasChangedAttributes
};
});
/**
* Provides utilities for views to work with xblocks.
*/
define(["jquery", "underscore", "gettext", "js/views/utils/view_utils", "js/utils/module"],
function($, _, gettext, ViewUtils, ModuleUtils) {
var addXBlock, deleteXBlock, createUpdateRequestData, updateXBlockField;
/**
* Adds an xblock based upon the data attributes of the specified add button. A promise
* is returned, and the new locator is passed to all done handlers.
* @param target The add button that was clicked upon.
* @returns {jQuery promise} A promise representing the addition of the xblock.
*/
addXBlock = function(target) {
var parentLocator = target.data('parent'),
category = target.data('category'),
displayName = target.data('default-name');
return ViewUtils.runOperationShowingMessage(gettext('Adding&hellip;'),
function() {
var addOperation = $.Deferred();
analytics.track('Created a ' + category, {
'course': course_location_analytics,
'display_name': displayName
});
$.postJSON(ModuleUtils.getUpdateUrl(),
{
'parent_locator': parentLocator,
'category': category,
'display_name': displayName
}, function(data) {
var locator = data.locator;
addOperation.resolve(locator);
});
return addOperation.promise();
});
};
/**
* Deletes the specified xblock.
* @param xblockInfo The model for the xblock to be deleted.
* @returns {jQuery promise} A promise representing the deletion of the xblock.
*/
deleteXBlock = function(xblockInfo) {
var deletion = $.Deferred(),
url = ModuleUtils.getUpdateUrl(xblockInfo.id);
ViewUtils.confirmThenRunOperation(gettext('Delete this component?'),
gettext('Deleting this component is permanent and cannot be undone.'),
gettext('Yes, delete this component'),
function() {
ViewUtils.runOperationShowingMessage(gettext('Deleting&hellip;'),
function() {
return $.ajax({
type: 'DELETE',
url: url
}).success(function() {
deletion.resolve();
});
});
});
return deletion.promise();
};
createUpdateRequestData = function(fieldName, newValue) {
var metadata = {};
metadata[fieldName] = newValue;
return {
metadata: metadata
};
};
/**
* Updates the specified field of an xblock to a new value.
* @param xblockInfo The XBlockInfo model representing the xblock.
* @param fieldName The xblock field name to be updated.
* @param newValue The new value for the field.
* @returns {jQuery promise} A promise representing the updating of the field.
*/
updateXBlockField = function(xblockInfo, fieldName, newValue) {
var requestData = createUpdateRequestData(fieldName, newValue);
return ViewUtils.runOperationShowingMessage(gettext('Saving&hellip;'),
function() {
return xblockInfo.save(requestData, { patch: true });
});
};
return {
'addXBlock': addXBlock,
'deleteXBlock': deleteXBlock,
'updateXBlockField': updateXBlockField
};
});
......@@ -67,7 +67,7 @@ define(["jquery", "underscore", "js/views/baseview", "xblock/runtime.v1"],
* represents this process.
* @param fragment The fragment returned from the xblock_handler
* @param element The element into which to render the fragment (defaults to this.$el)
* @returns {*} A promise representing the rendering process
* @returns {jQuery promise} A promise representing the rendering process
*/
renderXBlockFragment: function(fragment, element) {
var html = fragment.html,
......@@ -96,7 +96,7 @@ define(["jquery", "underscore", "js/views/baseview", "xblock/runtime.v1"],
* Dynamically loads all of an XBlock's dependent resources. This is an asynchronous
* process so a promise is returned.
* @param resources The resources to be rendered
* @returns {*} A promise representing the rendering process
* @returns {jQuery promise} A promise representing the rendering process
*/
addXBlockFragmentResources: function(resources) {
var self = this,
......@@ -136,7 +136,7 @@ define(["jquery", "underscore", "js/views/baseview", "xblock/runtime.v1"],
/**
* Loads the specified resource into the page.
* @param resource The resource to be loaded.
* @returns {*} A promise representing the loading of the resource.
* @returns {jQuery promise} A promise representing the loading of the resource.
*/
loadResource: function(resource) {
var head = $('head'),
......
......@@ -5,8 +5,8 @@
* XBlock field's value if it has been changed. If the user presses Escape, then any changes will
* be removed and the input hidden again.
*/
define(["jquery", "gettext", "js/views/baseview"],
function ($, gettext, BaseView) {
define(["js/views/baseview", "js/views/utils/xblock_utils"],
function (BaseView, XBlockViewUtils) {
var XBlockStringFieldEditor = BaseView.extend({
events: {
......@@ -73,32 +73,28 @@ define(["jquery", "gettext", "js/views/baseview"],
this.hideInput();
},
/**
* Refresh the model from the server so that it gets the latest publish and last modified information.
*/
refresh: function() {
this.model.fetch();
},
updateField: function() {
var xblockInfo = this.model,
var self = this,
xblockInfo = this.model,
newValue = this.getInput().val().trim(),
oldValue = xblockInfo.get(this.fieldName),
requestData = this.createUpdateRequestData(newValue);
oldValue = xblockInfo.get(this.fieldName);
// TODO: generalize this as not all xblock fields want to disallow empty strings...
if (newValue === '' || newValue === oldValue) {
this.cancelInput();
return;
}
this.runOperationShowingMessage(gettext('Saving&hellip;'),
function() {
return xblockInfo.save(requestData);
}).done(function() {
// Update publish and last modified information from the server.
xblockInfo.fetch();
return XBlockViewUtils.updateXBlockField(xblockInfo, this.fieldName, newValue).done(function() {
self.refresh();
});
},
createUpdateRequestData: function(newValue) {
var metadata = {};
metadata[this.fieldName] = newValue;
return {
metadata: metadata
};
},
handleKeyUp: function(event) {
if (event.keyCode === 27) { // Revert the changes if the user hits escape
this.cancelInput();
......
......@@ -8,3 +8,79 @@
// }
// --------------------
//.wrapper-xblock-header {
.view-outline,
.view-container {
.add-xblock-component {
text-align: center;
.add-button {
padding: 5px 10px;
background-color: $blue;
color: $white;
text-align: center;
}
}
.draggable-drop-indicator {
left: 0;
}
.nav-actions {
.collapse-all {
.expand-all {
display: none;
}
}
.expand-all {
.collapse-all {
display: none;
}
}
}
.outline-item {
padding: 6px 8px 8px 16px;
text-wrap: avoid;
border: 1px solid $gray;
margin: 5px;
background-color: $white;
.wrapper-xblock-header-secondary {
padding: 0px 8px 0px 26px;
.meta-info {
font-size: 12px;
}
}
.xblock-title {
width: 100%;
}
.actions-list {
.action-item {
display: inline-block;
}
}
}
.outline-item.collapsed {
.sortable-list,
.add-xblock-component {
display: none;
}
}
.item-actions {
.configure-button {
float: left;
margin-right: 13px;
color: #a4aab7;
}
}
}
......@@ -23,7 +23,7 @@
font-weight: 600;
}
.page-header-title-edit {
.xblock-title .xblock-field-input {
@extend %t-title4;
background: none repeat scroll 0 0 white;
border: 0;
......
......@@ -24,7 +24,8 @@ from django.utils.translation import ugettext as _
templates = ["basic-modal", "modal-button", "edit-xblock-modal",
"editor-mode-button", "upload-dialog", "image-modal",
"add-xblock-component", "add-xblock-component-button", "add-xblock-component-menu",
"add-xblock-component-menu-problem", "xblock-string-field-editor", "publish-xblock", "publish-history"]
"add-xblock-component-menu-problem", "xblock-string-field-editor", "publish-xblock", "publish-history",
"unit-outline"]
%>
<%block name="header_extras">
% for template_name in templates:
......@@ -41,13 +42,14 @@ templates = ["basic-modal", "modal-button", "edit-xblock-modal",
"js/collections/component_template", "xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
function(doc, $, XBlockInfo, ContainerPage, ComponentTemplates, xmoduleLoader) {
var templates = new ComponentTemplates(${component_templates | n}, {parse: true});
var mainXBlockInfo = new XBlockInfo(${json.dumps(xblock_info) | n});
var mainXBlockInfo = new XBlockInfo(${json.dumps(xblock_info) | n}, {parse: true});
var isUnitPage = ${json.dumps(is_unit_page)}
xmoduleLoader.done(function () {
var view = new ContainerPage({
el: $('#content'),
model: mainXBlockInfo,
action: "${action}",
templates: templates,
isUnitPage: isUnitPage
});
......@@ -70,9 +72,7 @@ templates = ["basic-modal", "modal-button", "edit-xblock-modal",
ancestor_url = xblock_studio_url(ancestor)
%>
% if ancestor_url:
<a href="${ancestor_url}" class="navigation-item navigation-link navigation-parent">
${ancestor.display_name_with_default | h}
</a>
<a href="${ancestor_url}" class="navigation-item navigation-link navigation-parent">${ancestor.display_name_with_default | h}</a>
% else:
<span class="navigation-item navigation-parent">${ancestor.display_name_with_default | h}</span>
% endif
......@@ -154,21 +154,8 @@ templates = ["basic-modal", "modal-button", "edit-xblock-modal",
</div>
<div class="wrapper-unit-tree-location bar-mod-content">
<h5 class="title">Unit Tree Location</h5>
<ol>
<li class="section">
<a href="${xblock_studio_url(section)}" class="section-item section-name">
<span class="section-name">${section.display_name_with_default}</span>
</a>
<ol>
<li class="subsection">
<div class="section-item">
<span class="subsection-name"><span class="subsection-name-value">${subsection.display_name_with_default}</span></span>
<div class="wrapper-unit-overview">
</div>
${units.enum_units(subsection, actions=False, selected=unit.location)}
</li>
</ol>
</li>
</ol>
</div>
</div>
% endif
......
<%inherit file="base.html" />
<%def name="online_help_token()"><% return "outline" %></%def>
<%!
import json
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>
<%block name="bodyclass">is-signedin course view-outline</%block>
<%namespace name='static' file='static_content.html'/>
<%block name="jsextra">
<script type="text/javascript">
require(["domReady!", "jquery", "js/views/pages/course_outline", "js/models/xblock_outline_info"],
function(doc, $, CourseOutlinePage, XBlockOutlineInfo) {
var courseXBlock = new XBlockOutlineInfo(${json.dumps(course_structure) | n}, { parse: true });
var view = new CourseOutlinePage({
el: $('#content'),
model: courseXBlock,
initialState: ${json.dumps(initial_state) | n}
});
view.render();
});
</script>
</%block>
<%block name="header_extras">
% for template_name in ['course-outline', 'xblock-string-field-editor']:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" />
</script>
% endfor
</%block>
<%block name="content">
<div class="wrapper-mast wrapper">
<header class="mast has-actions has-subtitle">
<h1 class="page-header">
<small class="subtitle">${_("Content")}</small>
<span class="sr">&gt; </span>${_("Course Outline")}
</h1>
<nav class="nav-actions">
<h3 class="sr">${_("Page Actions")}</h3>
<ul>
<li class="nav-item">
<a href="#" class="toggle-button toggle-button-expand-collapse collapse-all is-hidden">
<span class="collapse-all"><i class="icon-arrow-up"></i> <span class="label">${_("Collapse All Sections")}</span></span>
<span class="expand-all"><i class="icon-arrow-down"></i> <span class="label">${_("Expand All Sections")}</span></span>
</a>
</li>
<li class="nav-item">
<a href="#" class="button view-button add-button" data-category="chapter" data-parent="${context_course.location}" data-default-name="Section">
<i class="icon-plus"></i>${_('New Section')}
</a>
</li>
<li class="nav-item">
<a href="${lms_link}" rel="external" class="button view-button view-live-button">${_("View Live")}</a>
</li>
</ul>
</nav>
</header>
</div>
<div class="wrapper-content wrapper">
<section class="content">
<article class="content-primary" role="main">
<div class="wrapper-dnd">
<%
course_locator = context_course.location
%>
<article class="course-outline" data-locator="${course_locator}" data-course-key="${course_locator.course_key}">
</article>
</div>
<div class="ui-loading">
<p><span class="spin"><i class="icon-refresh"></i></span> <span class="copy">${_("Loading...")}</span></p>
</div>
</article>
<aside class="content-supplementary" role="complimentary">
<div class="bit">
<h3 class="title-3">${_("What can I do on this page?")}</h3>
<p>${_("You can create new sections and subsections, set the release date for sections, and create new units in existing subsections. You can set the assignment type for subsections that are to be graded, and you can open a subsection for further editing.")}</p>
<p>${_("In addition, you can drag and drop sections, subsections, and units to reorganize your course.")}</p>
</div>
</aside>
</section>
</div>
<footer></footer>
</%block>
<% if (parentInfo) { %>
<li class="outline-item outline-item-<%= xblockType %> <%= includesChildren ? 'is-collapsible' : '' %> is-draggable <%= isCollapsed ? 'collapsed' : '' %>"
data-parent="<%= parentInfo.get('id') %>" data-locator="<%= xblockInfo.get('id') %>">
<div class="wrapper-xblock-header">
<div class="wrapper-xblock-header-primary">
<% if (includesChildren) { %>
<h3 class="xblock-title expand-collapse <%= isCollapsed ? 'expand' : 'collapse' %>" title="<%= gettext('Collapse/Expand this Checklist') %>">
<i class="icon-caret-down ui-toggle-expansion"></i>
<% } else { %>
<h3 class="xblock-title">
<% } %>
<% if (xblockInfo.get('category') === 'vertical') { %>
<a href="<%= xblockInfo.get('studio_url') %>"><%= xblockInfo.get('display_name') %></a>
<% } else { %>
<span class="wrapper-xblock-field" data-field="display_name">
<span class="is-editable xblock-field-value"><%= xblockInfo.get('display_name') %></span>
</span>
<% } %>
</h3>
<div class="item-actions">
<ul class="actions-list">
<li class="action-item action-delete">
<a href="#" data-tooltip="<%= gettext('Delete') %>" class="delete-button action-button">
<i class="icon-remove"></i>
<span class="sr"><%= gettext('Delete') %></span>
</a>
</li>
</ul>
</div>
</div>
<div class="wrapper-xblock-header-secondary">
<% if (xblockInfo.get('edited_on')) { %>
<div class="meta-info">
<% if (xblockInfo.get('published')) { %>
<i class="icon-check"></i>
<%= gettext('Released:') %> Dec 31, 2015 at 21:00 UTC
<% } else { %>
<i class="icon-time"></i>
<%= gettext('Scheduled:') %> Dec 31, 2015 at 21:00 UTC
<% } %>
</div>
<% } %>
<div class="item-actions">
<ul class="actions-list">
</ul>
</div>
</div>
</div>
<% } %>
<% if (!parentInfo && xblockInfo.get('child_info') && xblockInfo.get('child_info').children.length === 0) { %>
<div class="no-content add-xblock-component">
<p><%= gettext("You haven't added any content to this course yet.") %>
<a href="#" class="add-button" data-category="<%= childCategory %>"
data-parent="<%= xblockInfo.get('id') %>" data-default-name="<%= defaultNewChildName %>">
<i class="icon-plus"></i><%= addChildLabel %>
</a>
</p>
</div>
<% } else { %>
<ol class="sortable-list sortable-<%= xblockType %>-list">
</ol>
<% if (childType) { %>
<div class="add-xblock-component">
<a href="#" class="add-button" data-category="<%= childCategory %>"
data-parent="<%= xblockInfo.get('id') %>" data-default-name="<%= defaultNewChildName %>">
<i class="icon-plus"></i><%= addChildLabel %>
</a>
</div>
<% } %>
<% } %>
<% if (parentInfo) { %>
</li>
<% } %>
......@@ -6,8 +6,8 @@
<small class="navigation navigation-parents subtitle">
<a href="/unit/TestCourse/branch/draft/block/vertical8eb" class="navigation-item navigation-link navigation-parent">Unit 1</a>
</small>
<div class="wrapper-xblock-field is-editable" data-field="display_name">
<h1 class="page-header-title xblock-field-value">Test Container</h1>
<div class="wrapper-xblock-field" data-field="display_name">
<h1 class="page-header-title is-editable xblock-field-value">Test Container</h1>
</div>
</div>
......@@ -62,31 +62,10 @@
<span class="tip"><span class="sr">Tip: </span>${_("Use this ID to link to this unit from other places in your course")}</span>
</p>
</div>
<div class="wrapper-unit-tree-location content-bit">
<div class="wrapper-unit-tree-location bar-mod-content">
<h5 class="title">Unit Tree Location</h5>
<ol>
<li class="section">
<a href="course-overview-url" class="section-item section-name">
<span class="section-name">Test Section</span>
</a>
<ol>
<li class="subsection">
<div class="section-item">
<span class="subsection-name"><span class="subsection-name-value">Test Subsection</span></span>
</div>
<ol class="sortable-unit-list">
<li class="courseware-unit unit is-draggable" data-locator="locator-container"
data-parent="" data-course-key="">
<div class="section-item editing">
<a href="unit-url" class="private-item">
<span class="unit-name">Test Container</span>
</a>
<div class="wrapper-unit-overview">
</div>
</ol>
</li>
</ol>
</li>
</ol>
</div>
</div>
</section>
......
<div id="content">
<div class="wrapper-mast wrapper">
<header class="mast has-actions has-subtitle">
<h1 class="page-header">
<small class="subtitle">Content</small>
<span class="sr">&gt; </span>Course Outline
</h1>
<nav class="nav-actions">
<h3 class="sr">Page Actions</h3>
<ul>
<li class="nav-item">
<a href="#" class="toggle-button toggle-button-expand-collapse collapse-all">
<span class="collapse-all"><i class="icon-arrow-up"></i> <span class="label">Collapse All Sections</span></span>
<span class="expand-all"><i class="icon-arrow-down"></i> <span class="label">Expand All Sections</span></span>
</a>
</li>
<li class="nav-item">
<a href="#" class="button view-button add-button" data-category="chapter" data-parent="mock-course" data-default-name="Section">
<i class="icon-plus"></i>New Section
</a>
</li>
<li class="nav-item">
<a href="#" rel="external" class="button view-button view-live-button" title="This link will open in a new browser window/tab">View Live</a>
</li>
</ul>
</nav>
</header>
</div>
<div class="wrapper-content wrapper">
<section class="content">
<article class="content-primary" role="main">
<div class="wrapper-dnd">
<article class="course-outline" data-locator="mock-course" data-course-key="slashes:MockCourse">
<div class="no-content add-xblock-component">
<p>You haven't added any content to this course yet.
<a href="#" class="add-button" data-category="chapter" data-parent="mock-course" data-default-name="Section">
<i class="icon-plus"></i>Add Section
</a>
</p>
</div>
</article>
</div>
<div class="ui-loading">
<p><span class="spin"><i class="icon-refresh"></i></span> <span class="copy">Loading...</span></p>
</div>
</article>
<aside class="content-supplementary" role="complimentary">
<div class="bit">
<h3 class="title-3">What can I do on this page?</h3>
<p>You can create new sections and subsections, set the release date for sections, and create new units in existing subsections. You can set the assignment type for subsections that are to be graded, and you can open a subsection for further editing.</p>
<p>In addition, you can drag and drop sections, subsections, and units to reorganize your course.</p>
</div>
</aside>
</section>
</div>
<footer>
</footer>
</div>
<% if (parentInfo) { %>
<li class="outline-item outline-item-<%= xblockType %>"
data-parent="<%= parentInfo.get('id') %>" data-locator="<%= xblockInfo.get('id') %>">
<div class="wrapper-xblock-header">
<div class="wrapper-xblock-header-primary">
<h3 class="xblock-title">
<a href="<%= xblockInfo.get('studio_url') %>"><%= xblockInfo.get('display_name') %></a>
</h3>
</div>
</div>
<% } %>
<ol class="sortable-list sortable-<%= xblockType %>-list">
</ol>
<% if (childType) { %>
<div class="add-xblock-component">
<a href="#" class="add-button" data-category="<%= childCategory %>"
data-parent="<%= xblockInfo.get('id') %>" data-default-name="<%= defaultNewChildName %>">
<i class="icon-plus"></i><%= addChildLabel %>
</a>
</div>
<% } %>
<% if (parentInfo) { %>
</li>
<% } %>
<% if (parentInfo) { %>
<li class="outline-item outline-item-<%= xblockType %> <%= includesChildren ? 'is-collapsible' : '' %> is-draggable <%= isCollapsed ? 'collapsed' : '' %>"
data-parent="<%= parentInfo.get('id') %>" data-locator="<%= xblockInfo.get('id') %>">
<span class="draggable-drop-indicator draggable-drop-indicator-before"><i class="icon-caret-right"></i></span>
<div class="wrapper-xblock-header">
<div class="wrapper-xblock-header-primary">
<% if (includesChildren) { %>
<h3 class="xblock-title expand-collapse <%= isCollapsed ? 'expand' : 'collapse' %>" title="<%= gettext('Collapse/Expand this Checklist') %>">
<i class="icon-caret-down ui-toggle-expansion"></i>
<% } else { %>
<h3 class="xblock-title">
<% } %>
<% if (xblockInfo.get('studio_url') && xblockInfo.get('category') !== 'chapter') { %>
<a href="<%= xblockInfo.get('studio_url') %>"><%= xblockInfo.get('display_name') %></a>
<% } else { %>
<span class="wrapper-xblock-field" data-field="display_name">
<span class="is-editable xblock-field-value"><%= xblockInfo.get('display_name') %></span>
</span>
<% } %>
</h3>
<div class="item-actions">
<ul class="actions-list">
<li class="action-item action-delete">
<a href="#" data-tooltip="<%= gettext('Delete') %>" class="delete-button action-button">
<i class="icon-remove"></i>
<span class="sr"><%= gettext('Delete') %></span>
</a>
</li>
<li class="actions-item drag">
<span data-tooltip="<%= gettext('Drag to reorder') %>" class="drag-handle">
<span class="sr"><%= gettext('Drag to reorder') %></span>
</span>
</li>
</ul>
</div>
</div>
<div class="wrapper-xblock-header-secondary">
<% if (xblockInfo.get('release_date')) { %>
<div class="meta-info">
<i class="icon-time"></i>
<%= gettext('Released:') %> <%= xblockInfo.get('release_date') %>
</div>
<% } %>
<div class="item-actions">
<ul class="actions-list">
</ul>
</div>
</div>
</div>
<% } %>
<% if (!parentInfo && xblockInfo.get('child_info') && xblockInfo.get('child_info').children.length === 0) { %>
<div class="no-content add-xblock-component">
<p><%= gettext("You haven't added any content to this course yet.") %>
<a href="#" class="add-button" data-category="<%= childCategory %>"
data-parent="<%= xblockInfo.get('id') %>" data-default-name="<%= defaultNewChildName %>">
<i class="icon-plus"></i><%= addChildLabel %>
</a>
</p>
</div>
<% } else { %>
<ol class="sortable-list sortable-<%= xblockType %>-list">
</ol>
<% if (childType) { %>
<div class="add-xblock-component">
<a href="#" class="add-button" data-category="<%= childCategory %>"
data-parent="<%= xblockInfo.get('id') %>" data-default-name="<%= defaultNewChildName %>">
<i class="icon-plus"></i><%= addChildLabel %>
</a>
</div>
<% } %>
<% } %>
<% if (parentInfo) { %>
<span class="draggable-drop-indicator draggable-drop-indicator-after"><i class="icon-caret-right"></i></span>
</li>
<% } %>
<div class="xblock-string-field-editor">
<input type="text" value="<%= value %>" class="xblock-field-input page-header-title-edit is-hidden" data-metadata-name="<%= fieldName %>">
</div>
<span class="xblock-string-field-editor">
<input type="text" value="<%= value %>" class="xblock-field-input xblock-field-input-<%= fieldName %> is-hidden"
data-metadata-name="<%= fieldName %>">
</span>
......@@ -82,6 +82,7 @@ urlpatterns += patterns(
url(r'^import/{}$'.format(settings.COURSE_KEY_PATTERN), 'import_handler'),
url(r'^import_status/{}/(?P<filename>.+)$'.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<view_name>[^/]+)$'.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'),
......
......@@ -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 \|'): [
......
......@@ -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 "{}(<browser>, {!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]
)
class CourseOutlineChild(PageObject):
def child_at(self, index, child_class=None):
"""
A mixin to a CourseOutline page object that will be used as a child of
:class:`CourseOutlineContainer`.
Returns the child at the specified index.
:type self: object
"""
NAME_SELECTOR = None
BODY_SELECTOR = None
if not child_class:
child_class = self.CHILD_CLASS
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 "{}(<browser>, {!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):
"""
......@@ -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):
......@@ -166,6 +188,12 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
"""
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=''):
"""
Find and click on first section name in course outline
......
"""
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()
......@@ -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")
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)
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)
# 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')
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment