Backbone version of the course outline page
STUD-1726
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
@shard_1
|
||||
Feature: CMS.Course Overview
|
||||
Feature: CMS.Course Outline
|
||||
In order to quickly view the details of a course's section and set release dates and grading
|
||||
As a course author
|
||||
I want to use the course overview page
|
||||
I want to use the course outline page
|
||||
|
||||
Scenario: The default layout for the overview page is to show sections in expanded view
|
||||
Scenario: The default layout for the outline page is to show sections in expanded view
|
||||
Given I have a course with multiple sections
|
||||
When I navigate to the course overview page
|
||||
When I navigate to the course outline page
|
||||
Then I see the "Collapse All Sections" link
|
||||
And all sections are expanded
|
||||
|
||||
Scenario: Expand /collapse for a course with no sections
|
||||
Given I have a course with no sections
|
||||
When I navigate to the course overview page
|
||||
When I navigate to the course outline page
|
||||
Then I do not see the "Collapse All Sections" link
|
||||
|
||||
Scenario: Collapse link appears after creating first section of a course
|
||||
Given I have a course with no sections
|
||||
When I navigate to the course overview page
|
||||
When I navigate to the course outline page
|
||||
And I add a section
|
||||
Then I see the "Collapse All Sections" link
|
||||
And all sections are expanded
|
||||
|
||||
Scenario: Collapse link is not removed after last section of a course is deleted
|
||||
Scenario: Collapse link is removed after last section of a course is deleted
|
||||
Given I have a course with 1 section
|
||||
And I navigate to the course overview page
|
||||
When I will confirm all alerts
|
||||
And I navigate to the course outline page
|
||||
And I press the "section" delete icon
|
||||
Then I see the "Collapse All Sections" link
|
||||
When I will confirm all alerts
|
||||
Then I do not see the "Collapse All Sections" link
|
||||
|
||||
Scenario: Collapsing all sections when all sections are expanded
|
||||
Given I navigate to the courseware page of a course with multiple sections
|
||||
Given I navigate to the outline page of a course with multiple sections
|
||||
And all sections are expanded
|
||||
When I click the "Collapse All Sections" link
|
||||
Then I see the "Expand All Sections" link
|
||||
And all sections are collapsed
|
||||
|
||||
Scenario: Collapsing all sections when 1 or more sections are already collapsed
|
||||
Given I navigate to the courseware page of a course with multiple sections
|
||||
Given I navigate to the outline page of a course with multiple sections
|
||||
And all sections are expanded
|
||||
When I collapse the first section
|
||||
And I click the "Collapse All Sections" link
|
||||
@@ -45,14 +45,14 @@ Feature: CMS.Course Overview
|
||||
And all sections are collapsed
|
||||
|
||||
Scenario: Expanding all sections when all sections are collapsed
|
||||
Given I navigate to the courseware page of a course with multiple sections
|
||||
Given I navigate to the outline page of a course with multiple sections
|
||||
And I click the "Collapse All Sections" link
|
||||
When I click the "Expand All Sections" link
|
||||
Then I see the "Collapse All Sections" link
|
||||
And all sections are expanded
|
||||
|
||||
Scenario: Expanding all sections when 1 or more sections are already expanded
|
||||
Given I navigate to the courseware page of a course with multiple sections
|
||||
Given I navigate to the outline page of a course with multiple sections
|
||||
And I click the "Collapse All Sections" link
|
||||
When I expand the first section
|
||||
And I click the "Expand All Sections" link
|
||||
@@ -48,75 +48,83 @@ def have_a_course_with_two_sections(step):
|
||||
display_name='Subsection Beta',)
|
||||
|
||||
|
||||
@step(u'I navigate to the course overview page$')
|
||||
def navigate_to_the_course_overview_page(step):
|
||||
@step(u'I navigate to the course outline page$')
|
||||
def navigate_to_the_course_outline_page(step):
|
||||
create_studio_user(is_staff=True)
|
||||
log_into_studio()
|
||||
course_locator = 'a.course-link'
|
||||
world.css_click(course_locator)
|
||||
|
||||
|
||||
@step(u'I navigate to the courseware page of a course with multiple sections')
|
||||
def nav_to_the_courseware_page_of_a_course_with_multiple_sections(step):
|
||||
@step(u'I navigate to the outline page of a course with multiple sections')
|
||||
def nav_to_the_outline_page_of_a_course_with_multiple_sections(step):
|
||||
step.given('I have a course with multiple sections')
|
||||
step.given('I navigate to the course overview page')
|
||||
step.given('I navigate to the course outline page')
|
||||
|
||||
|
||||
@step(u'I add a section')
|
||||
def i_add_a_section(step):
|
||||
add_section(name='My New Section That I Just Added')
|
||||
add_section()
|
||||
|
||||
|
||||
@step(u'I click the "([^"]*)" link$')
|
||||
def i_click_the_text_span(step, text):
|
||||
span_locator = '.toggle-button-sections span'
|
||||
assert_true(world.browser.is_element_present_by_css(span_locator))
|
||||
# first make sure that the expand/collapse text is the one you expected
|
||||
assert_true(world.css_has_value(span_locator, text))
|
||||
world.css_click(span_locator)
|
||||
@step(u'I press the "section" delete icon')
|
||||
def i_press_the_section_delete_icon(step):
|
||||
delete_locator = 'section .outline-item-section > .wrapper-xblock-header a.delete-button'
|
||||
world.css_click(delete_locator)
|
||||
|
||||
|
||||
@step(u'I collapse the first section$')
|
||||
def i_collapse_a_section(step):
|
||||
collapse_locator = 'section.courseware-section a.collapse'
|
||||
world.css_click(collapse_locator)
|
||||
@step(u'I will confirm all alerts')
|
||||
def i_confirm_all_alerts(step):
|
||||
confirm_locator = '.prompt .nav-actions a.action-primary'
|
||||
world.css_click(confirm_locator)
|
||||
|
||||
|
||||
@step(u'I expand the first section$')
|
||||
def i_expand_a_section(step):
|
||||
expand_locator = 'section.courseware-section a.expand'
|
||||
world.css_click(expand_locator)
|
||||
|
||||
|
||||
@step(u'I see the "([^"]*)" link$')
|
||||
def i_see_the_span_with_text(step, text):
|
||||
span_locator = '.toggle-button-sections span'
|
||||
assert_true(world.css_has_value(span_locator, text))
|
||||
@step(u'I see the "([^"]*) All Sections" link$')
|
||||
def i_see_the_collapse_expand_all_span(step, text):
|
||||
if text == "Collapse":
|
||||
span_locator = '.toggle-button-expand-collapse .collapse-all .label'
|
||||
elif text == "Expand":
|
||||
span_locator = '.toggle-button-expand-collapse .expand-all .label'
|
||||
assert_true(world.css_visible(span_locator))
|
||||
|
||||
|
||||
@step(u'I do not see the "([^"]*)" link$')
|
||||
def i_do_not_see_the_span_with_text(step, text):
|
||||
# Note that the span will exist on the page but not be visible
|
||||
span_locator = '.toggle-button-sections span'
|
||||
assert_true(world.is_css_present(span_locator))
|
||||
@step(u'I do not see the "([^"]*) All Sections" link$')
|
||||
def i_do_not_see_the_collapse_expand_all_span(step, text):
|
||||
if text == "Collapse":
|
||||
span_locator = '.toggle-button-expand-collapse .collapse-all .label'
|
||||
elif text == "Expand":
|
||||
span_locator = '.toggle-button-expand-collapse .expand-all .label'
|
||||
assert_false(world.css_visible(span_locator))
|
||||
|
||||
|
||||
@step(u'all sections are expanded$')
|
||||
def all_sections_are_expanded(step):
|
||||
@step(u'I click the "([^"]*) All Sections" link$')
|
||||
def i_click_the_collapse_expand_all_span(step, text):
|
||||
if text == "Collapse":
|
||||
span_locator = '.toggle-button-expand-collapse .collapse-all .label'
|
||||
elif text == "Expand":
|
||||
span_locator = '.toggle-button-expand-collapse .expand-all .label'
|
||||
assert_true(world.browser.is_element_present_by_css(span_locator))
|
||||
world.css_click(span_locator)
|
||||
|
||||
|
||||
@step(u'I ([^"]*) the first section$')
|
||||
def i_collapse_expand_a_section(step, text):
|
||||
if text == "collapse":
|
||||
locator = 'section .outline-item-section .ui-toggle-expansion'
|
||||
elif text == "expand":
|
||||
locator = 'section .outline-item-section .ui-toggle-expansion'
|
||||
world.css_click(locator)
|
||||
|
||||
|
||||
@step(u'all sections are ([^"]*)$')
|
||||
def all_sections_are_collapsed_or_expanded(step, text):
|
||||
subsection_locator = 'div.subsection-list'
|
||||
subsections = world.css_find(subsection_locator)
|
||||
for index in range(len(subsections)):
|
||||
assert_true(world.css_visible(subsection_locator, index=index))
|
||||
|
||||
|
||||
@step(u'all sections are collapsed$')
|
||||
def all_sections_are_collapsed(step):
|
||||
subsection_locator = 'div.subsection-list'
|
||||
subsections = world.css_find(subsection_locator)
|
||||
for index in range(len(subsections)):
|
||||
assert_false(world.css_visible(subsection_locator, index=index))
|
||||
if text == "collapsed":
|
||||
assert_false(world.css_visible(subsection_locator, index=index))
|
||||
elif text == "expanded":
|
||||
assert_true(world.css_visible(subsection_locator, index=index))
|
||||
|
||||
|
||||
@step(u"I change an assignment's grading status")
|
||||
@@ -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,
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
@shard_2
|
||||
Feature: CMS.Create Section
|
||||
In order offer a course on the edX platform
|
||||
As a course author
|
||||
I want to create and edit sections
|
||||
|
||||
Scenario: Add a new section to a course
|
||||
Given I have opened a new course in Studio
|
||||
When I click the New Section link
|
||||
And I enter the section name and click save
|
||||
Then I see my section on the Courseware page
|
||||
And I see a release date for my section
|
||||
And I see a link to create a new subsection
|
||||
|
||||
Scenario: Add a new section (with a quote in the name) to a course (bug #216)
|
||||
Given I have opened a new course in Studio
|
||||
When I click the New Section link
|
||||
And I enter a section name with a quote and click save
|
||||
Then I see my section name with a quote on the Courseware page
|
||||
And I click to edit the section name
|
||||
Then I see the complete section name with a quote in the editor
|
||||
|
||||
Scenario: Edit section release date
|
||||
Given I have opened a new course in Studio
|
||||
And I have added a new section
|
||||
When I click the Edit link for the release date
|
||||
And I set the section release date to 12/25/2013
|
||||
Then the section release date is updated
|
||||
And I see a "saving" notification
|
||||
|
||||
Scenario: Section name not clickable on editing release date
|
||||
Given I have opened a new course in Studio
|
||||
And I have added a new section
|
||||
When I click the Edit link for the release date
|
||||
And I click on section name in Section Release Date modal
|
||||
Then I see no form for editing section name in modal
|
||||
|
||||
Scenario: Delete section
|
||||
Given I have opened a new course in Studio
|
||||
And I have added a new section
|
||||
When I will confirm all alerts
|
||||
And I press the "section" delete icon
|
||||
And I confirm the prompt
|
||||
Then the section does not exist
|
||||
@@ -1,142 +0,0 @@
|
||||
# pylint: disable=C0111
|
||||
# pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from common import *
|
||||
from nose.tools import assert_equal # pylint: disable=E0611
|
||||
|
||||
|
||||
@step('I click the New Section link$')
|
||||
def i_click_new_section_link(_step):
|
||||
link_css = 'a.new-courseware-section-button'
|
||||
world.css_click(link_css)
|
||||
|
||||
|
||||
@step('I enter the section name and click save$')
|
||||
def i_save_section_name(_step):
|
||||
save_section_name('My Section')
|
||||
|
||||
|
||||
@step('I enter a section name with a quote and click save$')
|
||||
def i_save_section_name_with_quote(_step):
|
||||
save_section_name('Section with "Quote"')
|
||||
|
||||
|
||||
@step('I have added a new section$')
|
||||
def i_have_added_new_section(_step):
|
||||
add_section()
|
||||
|
||||
|
||||
@step('I click the Edit link for the release date$')
|
||||
def i_click_the_edit_link_for_the_release_date(_step):
|
||||
button_css = 'div.section-published-date a.edit-release-date'
|
||||
world.css_click(button_css)
|
||||
|
||||
|
||||
@step('I set the section release date to ([0-9/-]+)( [0-9:]+)?')
|
||||
def set_section_release_date(_step, datestring, timestring):
|
||||
if hasattr(timestring, "strip"):
|
||||
timestring = timestring.strip()
|
||||
if not timestring:
|
||||
timestring = "00:00"
|
||||
set_date_and_time(
|
||||
'input.start-date.date.hasDatepicker', datestring,
|
||||
'input.start-time.time.ui-timepicker-input', timestring)
|
||||
world.browser.click_link_by_text('Save')
|
||||
|
||||
|
||||
@step('I see a "(saving|deleting)" notification')
|
||||
def i_see_a_mini_notification(_step, _type):
|
||||
saving_css = '.wrapper-notification-mini'
|
||||
assert world.is_css_present(saving_css)
|
||||
|
||||
|
||||
@step('I see my section on the Courseware page$')
|
||||
def i_see_my_section_on_the_courseware_page(_step):
|
||||
see_my_section_on_the_courseware_page('My Section')
|
||||
|
||||
|
||||
@step('I see my section name with a quote on the Courseware page$')
|
||||
def i_see_my_section_name_with_quote_on_the_courseware_page(_step):
|
||||
see_my_section_on_the_courseware_page('Section with "Quote"')
|
||||
|
||||
|
||||
@step('I click to edit the section name$')
|
||||
def i_click_to_edit_section_name(_step):
|
||||
world.css_click('span.section-name-span')
|
||||
|
||||
|
||||
@step('I click on section name in Section Release Date modal$')
|
||||
def i_click_on_section_name_in_modal(_step):
|
||||
world.css_click('.modal-window .section-name')
|
||||
|
||||
|
||||
@step('I see no form for editing section name in modal$')
|
||||
def edit_section_name_form_not_exist(_step):
|
||||
assert world.is_css_not_present('.modal-window .section-name input')
|
||||
|
||||
|
||||
@step('I see the complete section name with a quote in the editor$')
|
||||
def i_see_complete_section_name_with_quote_in_editor(_step):
|
||||
css = '.section-name-edit input[type=text]'
|
||||
assert world.is_css_present(css)
|
||||
assert_equal(world.css_value(css), 'Section with "Quote"')
|
||||
|
||||
|
||||
@step('the section does not exist$')
|
||||
def section_does_not_exist(_step):
|
||||
css = 'h3[data-name="My Section"]'
|
||||
assert world.is_css_not_present(css)
|
||||
|
||||
|
||||
@step('I see a release date for my section$')
|
||||
def i_see_a_release_date_for_my_section(_step):
|
||||
import re
|
||||
|
||||
css = 'span.published-status'
|
||||
assert world.is_css_present(css)
|
||||
status_text = world.css_text(css)
|
||||
|
||||
# e.g. 11/06/2012 at 16:25
|
||||
msg = 'Release date:'
|
||||
date_regex = r'(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d\d?, \d{4}'
|
||||
if not re.search(date_regex, status_text):
|
||||
print status_text, date_regex
|
||||
time_regex = r'[0-2]\d:[0-5]\d( \w{3})?'
|
||||
if not re.search(time_regex, status_text):
|
||||
print status_text, time_regex
|
||||
match_string = r'%s\s+%s at %s' % (msg, date_regex, time_regex)
|
||||
if not re.match(match_string, status_text):
|
||||
print status_text, match_string
|
||||
assert re.match(match_string, status_text)
|
||||
|
||||
|
||||
@step('I see a link to create a new subsection$')
|
||||
def i_see_a_link_to_create_a_new_subsection(_step):
|
||||
css = 'a.new-subsection-item'
|
||||
assert world.is_css_present(css)
|
||||
|
||||
|
||||
@step('the section release date picker is not visible$')
|
||||
def the_section_release_date_picker_not_visible(_step):
|
||||
css = 'div.edit-subsection-publish-settings'
|
||||
assert not world.css_visible(css)
|
||||
|
||||
|
||||
@step('the section release date is updated$')
|
||||
def the_section_release_date_is_updated(_step):
|
||||
css = 'span.published-status'
|
||||
status_text = world.css_text(css)
|
||||
assert_equal(status_text, 'Release date: 12/25/2013 at 00:00 UTC')
|
||||
|
||||
|
||||
def save_section_name(name):
|
||||
name_css = '.new-section-name'
|
||||
save_css = '.new-section-name-save'
|
||||
world.css_fill(name_css, name)
|
||||
world.css_click(save_css)
|
||||
|
||||
|
||||
def see_my_section_on_the_courseware_page(name):
|
||||
section_css = 'span.section-name-span'
|
||||
assert world.css_has_text(section_css, name)
|
||||
@@ -1,75 +0,0 @@
|
||||
@shard_2
|
||||
Feature: CMS.Create Subsection
|
||||
In order offer a course on the edX platform
|
||||
As a course author
|
||||
I want to create and edit subsections
|
||||
|
||||
Scenario: Add a new subsection to a section
|
||||
Given I have opened a new course section in Studio
|
||||
When I click the New Subsection link
|
||||
And I enter the subsection name and click save
|
||||
Then I see my subsection on the Courseware page
|
||||
|
||||
Scenario: Add a new subsection (with a name containing a quote) to a section (bug #216)
|
||||
Given I have opened a new course section in Studio
|
||||
When I click the New Subsection link
|
||||
And I enter a subsection name with a quote and click save
|
||||
Then I see my subsection name with a quote on the Courseware page
|
||||
And I click on the subsection
|
||||
Then I see the complete subsection name with a quote in the editor
|
||||
|
||||
Scenario: Assign grading type to a subsection and verify it is still shown after refresh (bug #258)
|
||||
Given I have opened a new course section in Studio
|
||||
And I have added a new subsection
|
||||
And I mark it as Homework
|
||||
Then I see it marked as Homework
|
||||
And I reload the page
|
||||
Then I see it marked as Homework
|
||||
|
||||
# Safari has trouble saving the date in Sauce
|
||||
@skip_safari
|
||||
Scenario: Set a due date in a different year (bug #256)
|
||||
Given I have opened a new subsection in Studio
|
||||
And I set the subsection release date to 12/25/2011 03:00
|
||||
And I set the subsection due date to 01/02/2012 04:00
|
||||
Then I see the subsection release date is 12/25/2011 03:00
|
||||
And I see the subsection due date is 01/02/2012 04:00
|
||||
And I reload the page
|
||||
Then I see the subsection release date is 12/25/2011 03:00
|
||||
And I see the subsection due date is 01/02/2012 04:00
|
||||
|
||||
@skip_safari
|
||||
Scenario: Set release and due dates of subsection on enter
|
||||
Given I have opened a new subsection in Studio
|
||||
And I set the subsection release date on enter to 04/04/2014 03:00
|
||||
And I set the subsection due date on enter to 04/04/2014 04:00
|
||||
Then I see the subsection release date is 04/04/2014 03:00
|
||||
And I see the subsection due date is 04/04/2014 04:00
|
||||
And I reload the page
|
||||
Then I see the subsection release date is 04/04/2014 03:00
|
||||
And I see the subsection due date is 04/04/2014 04:00
|
||||
|
||||
Scenario: Delete a subsection
|
||||
Given I have opened a new course section in Studio
|
||||
And I have added a new subsection
|
||||
And I see my subsection on the Courseware page
|
||||
When I will confirm all alerts
|
||||
And I press the "subsection" delete icon
|
||||
And I confirm the prompt
|
||||
Then the subsection does not exist
|
||||
|
||||
@skip_safari
|
||||
Scenario: Sync to Section
|
||||
Given I have opened a new course section in Studio
|
||||
And I click the Edit link for the release date
|
||||
And I set the section release date to 01/02/2103
|
||||
And I have added a new subsection
|
||||
And I click on the subsection
|
||||
And I set the subsection release date to 06/20/2104
|
||||
Then I see the subsection release date is 06/20/2104
|
||||
And I reload the page
|
||||
Then I see the subsection release date is 06/20/2104
|
||||
And I click the link to sync release date to section
|
||||
And I wait for "1" second
|
||||
And I reload the page
|
||||
Then I see the subsection release date is 01/02/2103
|
||||
@@ -1,158 +0,0 @@
|
||||
# pylint: disable=C0111
|
||||
# pylint: disable=W0621
|
||||
|
||||
from lettuce import world, step
|
||||
from common import *
|
||||
from nose.tools import assert_equal # pylint: disable=E0611
|
||||
|
||||
############### ACTIONS ####################
|
||||
|
||||
|
||||
@step('I have opened a new course section in Studio$')
|
||||
def i_have_opened_a_new_course_section(step):
|
||||
open_new_course()
|
||||
add_section()
|
||||
|
||||
|
||||
@step('I have added a new subsection$')
|
||||
def i_have_added_a_new_subsection(step):
|
||||
add_subsection()
|
||||
|
||||
|
||||
@step('I have opened a new subsection in Studio$')
|
||||
def i_have_opened_a_new_subsection(step):
|
||||
step.given('I have opened a new course section in Studio')
|
||||
step.given('I have added a new subsection')
|
||||
world.css_click('span.subsection-name-value')
|
||||
|
||||
|
||||
@step('I click the New Subsection link')
|
||||
def i_click_the_new_subsection_link(step):
|
||||
world.css_click('a.new-subsection-item')
|
||||
|
||||
|
||||
@step('I enter the subsection name and click save$')
|
||||
def i_save_subsection_name(step):
|
||||
save_subsection_name('Subsection One')
|
||||
|
||||
|
||||
@step('I enter a subsection name with a quote and click save$')
|
||||
def i_save_subsection_name_with_quote(step):
|
||||
save_subsection_name('Subsection With "Quote"')
|
||||
|
||||
|
||||
@step('I click on the subsection$')
|
||||
def click_on_subsection(step):
|
||||
world.css_click('span.subsection-name-value')
|
||||
|
||||
|
||||
@step('I see the complete subsection name with a quote in the editor$')
|
||||
def i_see_complete_subsection_name_with_quote_in_editor(step):
|
||||
css = '.subsection-display-name-input'
|
||||
assert world.is_css_present(css)
|
||||
assert_equal(world.css_value(css), 'Subsection With "Quote"')
|
||||
|
||||
|
||||
@step('I set the subsection release date to ([0-9/-]+)( [0-9:]+)?')
|
||||
def set_subsection_release_date(_step, datestring, timestring):
|
||||
set_subsection_date('input#start_date', datestring, 'input#start_time', timestring)
|
||||
|
||||
|
||||
@step('I set the subsection release date on enter to ([0-9/-]+)( [0-9:]+)?')
|
||||
def set_subsection_release_date_on_enter(_step, datestring, timestring): # pylint: disable-msg=invalid-name
|
||||
set_subsection_date('input#start_date', datestring, 'input#start_time', timestring, 'ENTER')
|
||||
|
||||
|
||||
@step('I set the subsection due date to ([0-9/-]+)( [0-9:]+)?')
|
||||
def set_subsection_due_date(_step, datestring, timestring, key=None):
|
||||
if not world.css_visible('input#due_date'):
|
||||
world.css_click('.due-date-input .set-date')
|
||||
|
||||
assert world.css_visible('input#due_date')
|
||||
set_subsection_date('input#due_date', datestring, 'input#due_time', timestring, key)
|
||||
|
||||
|
||||
@step('I set the subsection due date on enter to ([0-9/-]+)( [0-9:]+)?')
|
||||
def set_subsection_due_date_on_enter(_step, datestring, timestring): # pylint: disable-msg=invalid-name
|
||||
set_subsection_due_date(_step, datestring, timestring, 'ENTER')
|
||||
|
||||
|
||||
@step('I mark it as Homework$')
|
||||
def i_mark_it_as_homework(step):
|
||||
world.css_click('a.menu-toggle')
|
||||
world.browser.click_link_by_text('Homework')
|
||||
|
||||
|
||||
@step('I see it marked as Homework$')
|
||||
def i_see_it_marked__as_homework(step):
|
||||
assert_equal(world.css_value(".status-label"), 'Homework')
|
||||
|
||||
|
||||
@step('I click the link to sync release date to section')
|
||||
def click_sync_release_date(step):
|
||||
world.css_click('.sync-date')
|
||||
|
||||
|
||||
############ ASSERTIONS ###################
|
||||
|
||||
|
||||
@step('I see my subsection on the Courseware page$')
|
||||
def i_see_my_subsection_on_the_courseware_page(step):
|
||||
see_subsection_name('Subsection One')
|
||||
|
||||
|
||||
@step('I see my subsection name with a quote on the Courseware page$')
|
||||
def i_see_my_subsection_name_with_quote_on_the_courseware_page(step):
|
||||
see_subsection_name('Subsection With "Quote"')
|
||||
|
||||
|
||||
@step('the subsection does not exist$')
|
||||
def the_subsection_does_not_exist(step):
|
||||
css = 'span.subsection-name'
|
||||
assert world.is_css_not_present(css)
|
||||
|
||||
|
||||
@step('I see the subsection release date is ([0-9/-]+)( [0-9:]+)?')
|
||||
def i_see_subsection_release(_step, datestring, timestring):
|
||||
if hasattr(timestring, "strip"):
|
||||
timestring = timestring.strip()
|
||||
assert_equal(datestring, get_date('input#start_date'))
|
||||
if timestring:
|
||||
assert_equal(timestring, get_date('input#start_time'))
|
||||
|
||||
|
||||
@step('I see the subsection due date is ([0-9/-]+)( [0-9:]+)?')
|
||||
def i_see_subsection_due(_step, datestring, timestring):
|
||||
if hasattr(timestring, "strip"):
|
||||
timestring = timestring.strip()
|
||||
assert_equal(datestring, get_date('input#due_date'))
|
||||
if timestring:
|
||||
assert_equal(timestring, get_date('input#due_time'))
|
||||
|
||||
|
||||
############ HELPER METHODS ###################
|
||||
def get_date(css):
|
||||
return world.css_find(css).first.value.strip()
|
||||
|
||||
|
||||
def save_subsection_name(name):
|
||||
name_css = 'input.new-subsection-name-input'
|
||||
save_css = 'input.new-subsection-name-save'
|
||||
world.css_fill(name_css, name)
|
||||
world.css_click(save_css)
|
||||
|
||||
|
||||
def see_subsection_name(name):
|
||||
css = 'span.subsection-name'
|
||||
assert world.is_css_present(css)
|
||||
css = 'span.subsection-name-value'
|
||||
assert world.css_has_text(css, name)
|
||||
|
||||
|
||||
def set_subsection_date(date_css, datestring, time_css, timestring, key=None):
|
||||
if hasattr(timestring, "strip"):
|
||||
timestring = timestring.strip()
|
||||
if not timestring:
|
||||
timestring = "00:00"
|
||||
|
||||
set_date_and_time(date_css, datestring, time_css, timestring, key)
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
define ["js/models/section", "js/views/section_show", "js/views/section_edit", "js/spec_helpers/create_sinon"], (Section, SectionShow, SectionEdit, create_sinon) ->
|
||||
|
||||
describe "SectionShow", ->
|
||||
describe "Basic", ->
|
||||
beforeEach ->
|
||||
spyOn(SectionShow.prototype, "switchToEditView")
|
||||
.andCallThrough()
|
||||
@model = new Section({
|
||||
id: 42
|
||||
name: "Life, the Universe, and Everything"
|
||||
})
|
||||
@view = new SectionShow({model: @model})
|
||||
@view.render()
|
||||
|
||||
it "should contain the model name", ->
|
||||
expect(@view.$el).toHaveText(@model.get('name'))
|
||||
|
||||
it "should call switchToEditView when clicked", ->
|
||||
@view.$el.click()
|
||||
expect(@view.switchToEditView).toHaveBeenCalled()
|
||||
|
||||
it "should pass the same element to SectionEdit when switching views", ->
|
||||
spyOn(SectionEdit.prototype, 'initialize').andCallThrough()
|
||||
@view.switchToEditView()
|
||||
expect(SectionEdit.prototype.initialize).toHaveBeenCalled()
|
||||
expect(SectionEdit.prototype.initialize.mostRecentCall.args[0].el).toEqual(@view.el)
|
||||
|
||||
describe "SectionEdit", ->
|
||||
describe "Basic", ->
|
||||
tpl = readFixtures('section-name-edit.underscore')
|
||||
feedback_tpl = readFixtures('system-feedback.underscore')
|
||||
|
||||
beforeEach ->
|
||||
setFixtures($("<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")
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
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
|
||||
}
|
||||
// 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.
|
||||
},
|
||||
|
||||
parse: function(response) {
|
||||
if (response.ancestor_info) {
|
||||
response.ancestor_info.ancestors = this.parseXBlockInfoList(response.ancestor_info.ancestors);
|
||||
}
|
||||
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;
|
||||
});
|
||||
|
||||
23
cms/static/js/models/xblock_outline_info.js
Normal file
23
cms/static/js/models/xblock_outline_info.js
Normal file
@@ -0,0 +1,23 @@
|
||||
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 () {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers",
|
||||
"js/views/feedback_prompt", "js/views/pages/container", "js/models/xblock_info", "jquery.simulate"],
|
||||
function ($, _, str, create_sinon, edit_helpers, Prompt, ContainerPage, XBlockInfo) {
|
||||
"js/views/pages/container", "js/models/xblock_info", "jquery.simulate"],
|
||||
function ($, _, str, create_sinon, edit_helpers, ContainerPage, XBlockInfo) {
|
||||
|
||||
describe("ContainerPage", function() {
|
||||
var lastRequest, renderContainerPage, expectComponents, respondWithHtml,
|
||||
@@ -31,11 +31,6 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
|
||||
display_name: initialDisplayName,
|
||||
category: 'vertical'
|
||||
});
|
||||
containerPage = new ContainerPage({
|
||||
model: model,
|
||||
templates: edit_helpers.mockComponentTemplates,
|
||||
el: $('#content')
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
@@ -53,8 +48,13 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
|
||||
);
|
||||
};
|
||||
|
||||
renderContainerPage = function(html, that) {
|
||||
requests = create_sinon.requests(that);
|
||||
renderContainerPage = function(html, test, options) {
|
||||
requests = create_sinon.requests(test);
|
||||
containerPage = new ContainerPage(_.extend(options || {}, {
|
||||
model: model,
|
||||
templates: edit_helpers.mockComponentTemplates,
|
||||
el: $('#content')
|
||||
}));
|
||||
containerPage.render();
|
||||
respondWithHtml(html);
|
||||
};
|
||||
@@ -82,33 +82,31 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
|
||||
respondWithHtml(mockContainerXBlockHtml);
|
||||
expect(containerPage.$('.ui-loading')).toHaveClass('is-hidden');
|
||||
});
|
||||
|
||||
it('inline edits the display name when performing a new action', function() {
|
||||
renderContainerPage(mockContainerXBlockHtml, this, {
|
||||
action: 'new'
|
||||
});
|
||||
expect(containerPage.$('.xblock-header').length).toBe(9);
|
||||
expect(containerPage.$('.wrapper-xblock .level-nesting')).not.toHaveClass('is-hidden');
|
||||
expect(containerPage.$('.xblock-field-input')).not.toHaveClass('is-hidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Editing the container", function() {
|
||||
var updatedDisplayName = 'Updated Test Container',
|
||||
expectEditCanceled, inlineEditDisplayName, displayNameElement, displayNameInput;
|
||||
|
||||
beforeEach(function() {
|
||||
displayNameElement = containerPage.$('.page-header-title');
|
||||
});
|
||||
expectEditCanceled;
|
||||
|
||||
afterEach(function() {
|
||||
edit_helpers.cancelModalIfShowing();
|
||||
});
|
||||
|
||||
inlineEditDisplayName = function(newTitle) {
|
||||
displayNameElement.click();
|
||||
expect(displayNameElement).toHaveClass('is-hidden');
|
||||
displayNameInput = containerPage.$('.xblock-string-field-editor .xblock-field-input');
|
||||
expect(displayNameInput).not.toHaveClass('is-hidden');
|
||||
displayNameInput.val(newTitle);
|
||||
};
|
||||
|
||||
expectEditCanceled = function(options) {
|
||||
var initialRequests;
|
||||
renderContainerPage(mockContainerXBlockHtml, options.that);
|
||||
expectEditCanceled = function(test, options) {
|
||||
var initialRequests, displayNameElement, displayNameInput;
|
||||
renderContainerPage(mockContainerXBlockHtml, test);
|
||||
initialRequests = requests.length;
|
||||
inlineEditDisplayName(options.newTitle);
|
||||
displayNameElement = containerPage.$('.page-header-title');
|
||||
displayNameInput = edit_helpers.inlineEdit(displayNameElement, options.newTitle);
|
||||
if (options.pressEscape) {
|
||||
displayNameInput.simulate("keydown", { keyCode: $.simulate.keyCode.ESCAPE });
|
||||
displayNameInput.simulate("keyup", { keyCode: $.simulate.keyCode.ESCAPE });
|
||||
@@ -117,15 +115,14 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
|
||||
}
|
||||
// No requests should be made when the edit is cancelled client-side
|
||||
expect(initialRequests).toBe(requests.length);
|
||||
expect(displayNameInput).toHaveClass('is-hidden');
|
||||
expect(displayNameElement).not.toHaveClass('is-hidden');
|
||||
expect(displayNameInput.val()).toBe(initialDisplayName);
|
||||
edit_helpers.verifyInlineEditChange(displayNameElement, initialDisplayName);
|
||||
expect(containerPage.model.get('display_name')).toBe(initialDisplayName);
|
||||
};
|
||||
|
||||
it('can edit itself', function() {
|
||||
var editButtons;
|
||||
var editButtons, displayNameElement;
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
displayNameElement = containerPage.$('.page-header-title');
|
||||
|
||||
// Click the root edit button
|
||||
editButtons = containerPage.$('.nav-actions .edit-button');
|
||||
@@ -148,7 +145,8 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
|
||||
expect(edit_helpers.isShowingModal()).toBeFalsy();
|
||||
|
||||
// Expect the last request be to refresh the container page
|
||||
expect(str.startsWith(lastRequest().url, '/xblock/locator-container/container_preview')).toBeTruthy();
|
||||
expect(str.startsWith(lastRequest().url,
|
||||
'/xblock/locator-container/container_preview')).toBeTruthy();
|
||||
create_sinon.respondWithJson(requests, {
|
||||
html: mockUpdatedContainerXBlockHtml,
|
||||
resources: []
|
||||
@@ -159,57 +157,57 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
|
||||
});
|
||||
|
||||
it('can inline edit the display name', function() {
|
||||
var displayNameElement, displayNameInput;
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
inlineEditDisplayName(updatedDisplayName);
|
||||
displayNameElement = containerPage.$('.page-header-title');
|
||||
displayNameInput = edit_helpers.inlineEdit(displayNameElement, updatedDisplayName);
|
||||
displayNameInput.change();
|
||||
// This is the response for the change operation.
|
||||
create_sinon.respondWithJson(requests, { });
|
||||
// This is the response for the subsequent fetch operation.
|
||||
create_sinon.respondWithJson(requests, {"display_name": updatedDisplayName});
|
||||
expect(displayNameInput).toHaveClass('is-hidden');
|
||||
expect(displayNameElement).not.toHaveClass('is-hidden');
|
||||
expect(displayNameElement.text().trim()).toBe(updatedDisplayName);
|
||||
edit_helpers.verifyInlineEditChange(displayNameElement, updatedDisplayName);
|
||||
expect(containerPage.model.get('display_name')).toBe(updatedDisplayName);
|
||||
});
|
||||
|
||||
it('does not change the title when a display name update fails', function() {
|
||||
var initialRequests, displayNameElement, displayNameInput;
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
inlineEditDisplayName(updatedDisplayName);
|
||||
var initialRequests = requests.length;
|
||||
displayNameElement = containerPage.$('.page-header-title');
|
||||
displayNameInput = edit_helpers.inlineEdit(displayNameElement, updatedDisplayName);
|
||||
initialRequests = requests.length;
|
||||
displayNameInput.change();
|
||||
create_sinon.respondWithError(requests);
|
||||
// No fetch operation should occur.
|
||||
expect(initialRequests + 1).toBe(requests.length);
|
||||
expect(displayNameElement).toHaveClass('is-hidden');
|
||||
expect(displayNameInput).not.toHaveClass('is-hidden');
|
||||
expect(displayNameInput.val().trim()).toBe(updatedDisplayName);
|
||||
edit_helpers.verifyInlineEditChange(displayNameElement, initialDisplayName, updatedDisplayName);
|
||||
expect(containerPage.model.get('display_name')).toBe(initialDisplayName);
|
||||
});
|
||||
|
||||
it('trims whitespace from the display name', function() {
|
||||
var displayNameElement, displayNameInput;
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
inlineEditDisplayName(updatedDisplayName + ' ');
|
||||
displayNameElement = containerPage.$('.page-header-title');
|
||||
displayNameInput = edit_helpers.inlineEdit(displayNameElement, updatedDisplayName + ' ');
|
||||
displayNameInput.change();
|
||||
// This is the response for the change operation.
|
||||
create_sinon.respondWithJson(requests, { });
|
||||
// This is the response for the subsequent fetch operation.
|
||||
create_sinon.respondWithJson(requests, {"display_name": updatedDisplayName});
|
||||
expect(displayNameInput).toHaveClass('is-hidden');
|
||||
expect(displayNameElement).not.toHaveClass('is-hidden');
|
||||
expect(displayNameElement.text()).toBe(updatedDisplayName);
|
||||
edit_helpers.verifyInlineEditChange(displayNameElement, updatedDisplayName);
|
||||
expect(containerPage.model.get('display_name')).toBe(updatedDisplayName);
|
||||
});
|
||||
|
||||
it('does not change the title when input is the empty string', function() {
|
||||
expectEditCanceled({newTitle: '', pressEscape: false, that: this});
|
||||
expectEditCanceled(this, {newTitle: ''});
|
||||
});
|
||||
|
||||
it('does not change the title when input is whitespace-only', function() {
|
||||
expectEditCanceled({newTitle: ' ', pressEscape: false, that: this});
|
||||
expectEditCanceled(this, {newTitle: ' '});
|
||||
});
|
||||
|
||||
it('can cancel an inline edit', function() {
|
||||
expectEditCanceled({newTitle: updatedDisplayName, pressEscape: true, that: this});
|
||||
expectEditCanceled(this, {newTitle: updatedDisplayName, pressEscape: true});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -288,8 +286,8 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
|
||||
});
|
||||
|
||||
describe("xblock operations", function() {
|
||||
var getGroupElement, expectNumComponents,
|
||||
NUM_GROUPS = 2, NUM_COMPONENTS_PER_GROUP = 3, GROUP_TO_TEST = "A",
|
||||
var getGroupElement,
|
||||
NUM_COMPONENTS_PER_GROUP = 3, GROUP_TO_TEST = "A",
|
||||
allComponentsInGroup = _.map(
|
||||
_.range(NUM_COMPONENTS_PER_GROUP),
|
||||
function(index) { return 'locator-component-' + GROUP_TO_TEST + (index + 1); }
|
||||
@@ -299,19 +297,12 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
|
||||
return containerPage.$("[data-locator='locator-group-" + GROUP_TO_TEST + "']");
|
||||
};
|
||||
|
||||
expectNumComponents = function(numComponents) {
|
||||
expect(containerPage.$('.wrapper-xblock.level-element').length).toBe(
|
||||
numComponents * NUM_GROUPS
|
||||
);
|
||||
};
|
||||
|
||||
describe("Deleting an xblock", function() {
|
||||
var clickDelete, deleteComponent, deleteComponentWithSuccess,
|
||||
promptSpies;
|
||||
promptSpy;
|
||||
|
||||
beforeEach(function() {
|
||||
promptSpies = spyOnConstructor(Prompt, "Warning", ["show", "hide"]);
|
||||
promptSpies.show.andReturn(this.promptSpies);
|
||||
promptSpy = edit_helpers.createPromptSpy();
|
||||
});
|
||||
|
||||
clickDelete = function(componentIndex, clickNo) {
|
||||
@@ -323,18 +314,8 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
|
||||
// click the requested delete button
|
||||
deleteButtons[componentIndex].click();
|
||||
|
||||
// expect delete confirmation
|
||||
expect(promptSpies.constructor).toHaveBeenCalled();
|
||||
|
||||
// no components should be deleted yet
|
||||
expectNumComponents(NUM_COMPONENTS_PER_GROUP);
|
||||
|
||||
// click 'Yes' or 'No' on delete confirmation
|
||||
if (clickNo) {
|
||||
promptSpies.constructor.mostRecentCall.args[0].actions.secondary.click(promptSpies);
|
||||
} else {
|
||||
promptSpies.constructor.mostRecentCall.args[0].actions.primary.click(promptSpies);
|
||||
}
|
||||
// click the 'yes' or 'no' button in the prompt
|
||||
edit_helpers.confirmPrompt(promptSpy, clickNo);
|
||||
};
|
||||
|
||||
deleteComponent = function(componentIndex) {
|
||||
@@ -346,10 +327,8 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
|
||||
new RegExp("locator-component-" + GROUP_TO_TEST + (componentIndex + 1))
|
||||
);
|
||||
|
||||
// second request if a fetch of the container.
|
||||
expect(lastRequest().url).toMatch(
|
||||
new RegExp("locator-container")
|
||||
);
|
||||
// final request to refresh the xblock info
|
||||
create_sinon.expectJsonRequest(requests, 'GET', '/xblock/locator-container');
|
||||
};
|
||||
|
||||
deleteComponentWithSuccess = function(componentIndex) {
|
||||
@@ -414,13 +393,9 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
|
||||
});
|
||||
|
||||
describe("Duplicating an xblock", function() {
|
||||
var clickDuplicate, duplicateComponentWithResponse, duplicateComponentWithSuccess,
|
||||
var clickDuplicate, duplicateComponentWithSuccess,
|
||||
refreshXBlockSpies;
|
||||
|
||||
beforeEach(function() {
|
||||
refreshXBlockSpies = spyOn(containerPage, "refreshXBlock");
|
||||
});
|
||||
|
||||
clickDuplicate = function(componentIndex) {
|
||||
|
||||
// find all duplicate buttons for the given group
|
||||
@@ -431,37 +406,21 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
|
||||
duplicateButtons[componentIndex].click();
|
||||
};
|
||||
|
||||
duplicateComponentWithResponse = function(componentIndex, responseCode) {
|
||||
var request;
|
||||
duplicateComponentWithSuccess = function(componentIndex) {
|
||||
refreshXBlockSpies = spyOn(containerPage, "refreshXBlock");
|
||||
|
||||
// click duplicate button for given component
|
||||
clickDuplicate(componentIndex);
|
||||
|
||||
// verify content of request
|
||||
request = lastRequest();
|
||||
expect(request.url).toEqual("/xblock/");
|
||||
expect(request.method).toEqual("POST");
|
||||
expect(JSON.parse(request.requestBody)).toEqual(
|
||||
JSON.parse(
|
||||
'{' +
|
||||
'"duplicate_source_locator": "locator-component-' + GROUP_TO_TEST + (componentIndex + 1) + '",' +
|
||||
'"parent_locator": "locator-group-' + GROUP_TO_TEST +
|
||||
'"}'
|
||||
)
|
||||
);
|
||||
create_sinon.expectJsonRequest(requests, 'POST', '/xblock/', {
|
||||
'duplicate_source_locator': 'locator-component-' + GROUP_TO_TEST + (componentIndex + 1),
|
||||
'parent_locator': 'locator-group-' + GROUP_TO_TEST
|
||||
});
|
||||
|
||||
// send the response
|
||||
request.respond(
|
||||
responseCode,
|
||||
{ "Content-Type": "application/json" },
|
||||
JSON.stringify({'locator': 'locator-duplicated-component'})
|
||||
);
|
||||
};
|
||||
|
||||
duplicateComponentWithSuccess = function(componentIndex) {
|
||||
|
||||
// duplicate component with an 'OK' response code
|
||||
duplicateComponentWithResponse(componentIndex, 200);
|
||||
create_sinon.respondWithJson(requests, {
|
||||
'locator': 'locator-duplicated-component'
|
||||
});
|
||||
|
||||
// expect parent container to be refreshed
|
||||
expect(refreshXBlockSpies).toHaveBeenCalled();
|
||||
@@ -494,6 +453,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
|
||||
it('does not duplicate an xblock upon failure', function () {
|
||||
var notificationSpy = edit_helpers.createNotificationSpy();
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
refreshXBlockSpies = spyOn(containerPage, "refreshXBlock");
|
||||
clickDuplicate(0);
|
||||
edit_helpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
|
||||
create_sinon.respondWithError(requests);
|
||||
|
||||
@@ -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]; };
|
||||
|
||||
|
||||
403
cms/static/js/spec/views/pages/course_outline_spec.js
Normal file
403
cms/static/js/spec/views/pages/course_outline_spec.js
Normal file
@@ -0,0 +1,403 @@
|
||||
define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers", "js/views/utils/view_utils",
|
||||
"js/views/pages/course_outline", "js/models/xblock_outline_info"],
|
||||
function ($, create_sinon, view_helpers, ViewUtils, CourseOutlinePage, XBlockOutlineInfo) {
|
||||
|
||||
describe("CourseOutlinePage", function() {
|
||||
var createCourseOutlinePage, displayNameInput, model, outlinePage, requests,
|
||||
getHeaderElement, expandAndVerifyState, collapseAndVerifyState,
|
||||
createMockCourseJSON, createMockSectionJSON, createMockSubsectionJSON,
|
||||
mockCourseJSON, mockEmptyCourseJSON, mockSingleSectionCourseJSON,
|
||||
mockOutlinePage = readFixtures('mock/mock-course-outline-page.underscore');
|
||||
|
||||
createMockCourseJSON = function(id, displayName, children) {
|
||||
return {
|
||||
id: id,
|
||||
display_name: displayName,
|
||||
category: 'course',
|
||||
studio_url: '/course/slashes:MockCourse',
|
||||
is_container: true,
|
||||
has_changes: false,
|
||||
published: true,
|
||||
edited_on: 'Jul 02, 2014 at 20:56 UTC',
|
||||
edited_by: 'MockUser',
|
||||
child_info: {
|
||||
display_name: 'Section',
|
||||
category: 'chapter',
|
||||
children: children
|
||||
}
|
||||
};
|
||||
};
|
||||
createMockSectionJSON = function(id, displayName, children) {
|
||||
return {
|
||||
id: id,
|
||||
category: 'chapter',
|
||||
display_name: displayName,
|
||||
studio_url: '/course/slashes:MockCourse',
|
||||
is_container: true,
|
||||
has_changes: false,
|
||||
published: true,
|
||||
edited_on: 'Jul 02, 2014 at 20:56 UTC',
|
||||
edited_by: 'MockUser',
|
||||
child_info: {
|
||||
category: 'sequential',
|
||||
display_name: 'Subsection',
|
||||
children: children
|
||||
}
|
||||
};
|
||||
};
|
||||
createMockSubsectionJSON = function(id, displayName, children) {
|
||||
return {
|
||||
id: id,
|
||||
display_name: displayName,
|
||||
category: 'sequential',
|
||||
studio_url: '/course/slashes:MockCourse',
|
||||
is_container: true,
|
||||
has_changes: false,
|
||||
published: true,
|
||||
edited_on: 'Jul 02, 2014 at 20:56 UTC',
|
||||
edited_by: 'MockUser',
|
||||
child_info: {
|
||||
category: 'vertical',
|
||||
display_name: 'Unit',
|
||||
children: children
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
getHeaderElement = function(selector) {
|
||||
var element = outlinePage.$(selector);
|
||||
return element.find('> .wrapper-xblock-header');
|
||||
};
|
||||
|
||||
expandAndVerifyState = function(selector) {
|
||||
var element = outlinePage.$(selector);
|
||||
getHeaderElement(selector).find('.ui-toggle-expansion').click();
|
||||
expect(element).not.toHaveClass('collapsed');
|
||||
};
|
||||
|
||||
collapseAndVerifyState = function(selector) {
|
||||
var element = outlinePage.$(selector);
|
||||
getHeaderElement(selector).find('.ui-toggle-expansion').click();
|
||||
expect(element).toHaveClass('collapsed');
|
||||
};
|
||||
|
||||
createCourseOutlinePage = function(test, courseJSON, createOnly) {
|
||||
requests = create_sinon.requests(test);
|
||||
model = new XBlockOutlineInfo(courseJSON, { parse: true });
|
||||
outlinePage = new CourseOutlinePage({
|
||||
model: model,
|
||||
el: $('#content')
|
||||
});
|
||||
if (!createOnly) {
|
||||
outlinePage.render();
|
||||
}
|
||||
return outlinePage;
|
||||
};
|
||||
|
||||
beforeEach(function () {
|
||||
view_helpers.installMockAnalytics();
|
||||
view_helpers.installViewTemplates();
|
||||
view_helpers.installTemplate('course-outline');
|
||||
view_helpers.installTemplate('xblock-string-field-editor');
|
||||
appendSetFixtures(mockOutlinePage);
|
||||
mockCourseJSON = createMockCourseJSON('mock-course', 'Mock Course', [
|
||||
createMockSectionJSON('mock-section', 'Mock Section', [
|
||||
createMockSubsectionJSON('mock-subsection', 'Mock Subsection', [{
|
||||
id: 'mock-unit',
|
||||
display_name: 'Mock Unit',
|
||||
category: 'vertical',
|
||||
studio_url: '/container/mock-unit',
|
||||
is_container: true,
|
||||
has_changes: true,
|
||||
published: false,
|
||||
edited_on: 'Jul 02, 2014 at 20:56 UTC',
|
||||
edited_by: 'MockUser'
|
||||
}
|
||||
])
|
||||
])
|
||||
]);
|
||||
mockEmptyCourseJSON = createMockCourseJSON('mock-course', 'Mock Course', []);
|
||||
mockSingleSectionCourseJSON = createMockCourseJSON('mock-course', 'Mock Course', [
|
||||
createMockSectionJSON('mock-section', 'Mock Section', [])
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
view_helpers.removeMockAnalytics();
|
||||
});
|
||||
|
||||
describe('Initial display', function() {
|
||||
it('can render itself', function() {
|
||||
createCourseOutlinePage(this, mockCourseJSON);
|
||||
expect(outlinePage.$('.sortable-course-list')).toExist();
|
||||
expect(outlinePage.$('.sortable-section-list')).toExist();
|
||||
expect(outlinePage.$('.sortable-subsection-list')).toExist();
|
||||
});
|
||||
|
||||
it('shows a loading indicator', function() {
|
||||
createCourseOutlinePage(this, mockCourseJSON, true);
|
||||
expect(outlinePage.$('.ui-loading')).not.toHaveClass('is-hidden');
|
||||
outlinePage.render();
|
||||
expect(outlinePage.$('.ui-loading')).toHaveClass('is-hidden');
|
||||
});
|
||||
|
||||
it('shows subsections initially collapsed', function() {
|
||||
var subsectionElement;
|
||||
createCourseOutlinePage(this, mockCourseJSON);
|
||||
subsectionElement = outlinePage.$('.outline-item-subsection');
|
||||
expect(subsectionElement).toHaveClass('collapsed');
|
||||
expect(outlinePage.$('.outline-item-unit')).not.toExist();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Button bar", function() {
|
||||
it('can add a section', function() {
|
||||
createCourseOutlinePage(this, mockEmptyCourseJSON);
|
||||
outlinePage.$('.nav-actions .add-button').click();
|
||||
create_sinon.expectJsonRequest(requests, 'POST', '/xblock/', {
|
||||
'category': 'chapter',
|
||||
'display_name': 'Section',
|
||||
'parent_locator': 'mock-course'
|
||||
});
|
||||
create_sinon.respondWithJson(requests, {
|
||||
"locator": 'mock-section',
|
||||
"courseKey": 'slashes:MockCourse'
|
||||
});
|
||||
create_sinon.expectJsonRequest(requests, 'GET', '/xblock/outline/mock-course');
|
||||
create_sinon.respondWithJson(requests, mockSingleSectionCourseJSON);
|
||||
expect(outlinePage.$('.no-content')).not.toExist();
|
||||
expect(outlinePage.$('.sortable-course-list li').data('locator')).toEqual('mock-section');
|
||||
});
|
||||
|
||||
it('can add a second section', function() {
|
||||
var sectionElements;
|
||||
createCourseOutlinePage(this, mockSingleSectionCourseJSON);
|
||||
outlinePage.$('.nav-actions .add-button').click();
|
||||
create_sinon.expectJsonRequest(requests, 'POST', '/xblock/', {
|
||||
'category': 'chapter',
|
||||
'display_name': 'Section',
|
||||
'parent_locator': 'mock-course'
|
||||
});
|
||||
create_sinon.respondWithJson(requests, {
|
||||
"locator": 'mock-section-2',
|
||||
"courseKey": 'slashes:MockCourse'
|
||||
});
|
||||
// Expect the UI to just fetch the new section and repaint it
|
||||
create_sinon.expectJsonRequest(requests, 'GET', '/xblock/outline/mock-section-2');
|
||||
create_sinon.respondWithJson(requests,
|
||||
createMockSectionJSON('mock-section-2', 'Mock Section 2', []));
|
||||
sectionElements = outlinePage.$('.sortable-course-list .outline-item-section');
|
||||
expect(sectionElements.length).toBe(2);
|
||||
expect($(sectionElements[0]).data('locator')).toEqual('mock-section');
|
||||
expect($(sectionElements[1]).data('locator')).toEqual('mock-section-2');
|
||||
});
|
||||
|
||||
it('can expand and collapse all sections', function() {
|
||||
createCourseOutlinePage(this, mockCourseJSON, false);
|
||||
outlinePage.$('.nav-actions .toggle-button-expand-collapse').click();
|
||||
expect(outlinePage.$('.outline-item-section')).toHaveClass('collapsed');
|
||||
outlinePage.$('.nav-actions .toggle-button-expand-collapse').click();
|
||||
expect(outlinePage.$('.outline-item-section')).not.toHaveClass('collapsed');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Empty course", function() {
|
||||
it('shows an empty course message initially', function() {
|
||||
createCourseOutlinePage(this, mockEmptyCourseJSON);
|
||||
expect(outlinePage.$('.no-content')).not.toHaveClass('is-hidden');
|
||||
expect(outlinePage.$('.no-content .add-button')).toExist();
|
||||
});
|
||||
|
||||
it('can add a section', function() {
|
||||
createCourseOutlinePage(this, mockEmptyCourseJSON);
|
||||
$('.no-content .add-button').click();
|
||||
create_sinon.expectJsonRequest(requests, 'POST', '/xblock/', {
|
||||
'category': 'chapter',
|
||||
'display_name': 'Section',
|
||||
'parent_locator': 'mock-course'
|
||||
});
|
||||
create_sinon.respondWithJson(requests, {
|
||||
"locator": "mock-section",
|
||||
"courseKey": "slashes:MockCourse"
|
||||
});
|
||||
create_sinon.expectJsonRequest(requests, 'GET', '/xblock/outline/mock-course');
|
||||
create_sinon.respondWithJson(requests, mockSingleSectionCourseJSON);
|
||||
expect(outlinePage.$('.no-content')).not.toExist();
|
||||
expect(outlinePage.$('.sortable-course-list li').data('locator')).toEqual('mock-section');
|
||||
});
|
||||
|
||||
it('remains empty if an add fails', function() {
|
||||
var requestCount;
|
||||
createCourseOutlinePage(this, mockEmptyCourseJSON);
|
||||
$('.no-content .add-button').click();
|
||||
create_sinon.expectJsonRequest(requests, 'POST', '/xblock/', {
|
||||
'category': 'chapter',
|
||||
'display_name': 'Section',
|
||||
'parent_locator': 'mock-course'
|
||||
});
|
||||
requestCount = requests.length;
|
||||
create_sinon.respondWithError(requests);
|
||||
expect(requests.length).toBe(requestCount); // No additional requests should be made
|
||||
expect(outlinePage.$('.no-content')).not.toHaveClass('is-hidden');
|
||||
expect(outlinePage.$('.no-content .add-button')).toExist();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Section", function() {
|
||||
it('can be deleted', function() {
|
||||
var promptSpy = view_helpers.createPromptSpy();
|
||||
createCourseOutlinePage(this, mockSingleSectionCourseJSON);
|
||||
outlinePage.$('.outline-item-section .delete-button').click();
|
||||
view_helpers.confirmPrompt(promptSpy);
|
||||
create_sinon.expectJsonRequest(requests, 'DELETE', '/xblock/mock-section');
|
||||
create_sinon.respondWithJson(requests, {});
|
||||
create_sinon.expectJsonRequest(requests, 'GET', '/xblock/outline/mock-course');
|
||||
create_sinon.respondWithJson(requests, mockEmptyCourseJSON);
|
||||
expect(outlinePage.$('.no-content')).not.toHaveClass('is-hidden');
|
||||
expect(outlinePage.$('.no-content .add-button')).toExist();
|
||||
});
|
||||
|
||||
it('remains visible if its deletion fails', function() {
|
||||
var promptSpy = view_helpers.createPromptSpy(),
|
||||
requestCount;
|
||||
createCourseOutlinePage(this, mockSingleSectionCourseJSON);
|
||||
outlinePage.$('.outline-item-section .delete-button').click();
|
||||
view_helpers.confirmPrompt(promptSpy);
|
||||
create_sinon.expectJsonRequest(requests, 'DELETE', '/xblock/mock-section');
|
||||
requestCount = requests.length;
|
||||
create_sinon.respondWithError(requests);
|
||||
expect(requests.length).toBe(requestCount); // No additional requests should be made
|
||||
expect(outlinePage.$('.sortable-course-list li').data('locator')).toEqual('mock-section');
|
||||
});
|
||||
|
||||
it('can add a subsection', function() {
|
||||
createCourseOutlinePage(this, mockCourseJSON);
|
||||
outlinePage.$('.outline-item-section > .add-xblock-component .add-button').click();
|
||||
create_sinon.expectJsonRequest(requests, 'POST', '/xblock/', {
|
||||
'category': 'sequential',
|
||||
'display_name': 'Subsection',
|
||||
'parent_locator': 'mock-section'
|
||||
});
|
||||
create_sinon.respondWithJson(requests, {
|
||||
"locator": "new-mock-subsection",
|
||||
"courseKey": "slashes:MockCourse"
|
||||
});
|
||||
// Note: verification of the server response and the UI's handling of it
|
||||
// is handled in the acceptance tests.
|
||||
create_sinon.expectJsonRequest(requests, 'GET', '/xblock/outline/mock-section');
|
||||
|
||||
});
|
||||
|
||||
it('can be renamed inline', function() {
|
||||
var updatedDisplayName = 'Updated Section Name',
|
||||
displayNameElement,
|
||||
sectionModel;
|
||||
createCourseOutlinePage(this, mockCourseJSON);
|
||||
displayNameElement = getHeaderElement('.outline-item-section').find('.xblock-field-value');
|
||||
displayNameInput = view_helpers.inlineEdit(displayNameElement, updatedDisplayName);
|
||||
displayNameInput.change();
|
||||
// This is the response for the change operation.
|
||||
create_sinon.respondWithJson(requests, { });
|
||||
// This is the response for the subsequent fetch operation.
|
||||
create_sinon.respondWithJson(requests, {"display_name": updatedDisplayName});
|
||||
view_helpers.verifyInlineEditChange(displayNameElement, updatedDisplayName);
|
||||
sectionModel = outlinePage.model.get('child_info').children[0];
|
||||
expect(sectionModel.get('display_name')).toBe(updatedDisplayName);
|
||||
});
|
||||
|
||||
it('can be expanded and collapsed', function() {
|
||||
createCourseOutlinePage(this, mockCourseJSON);
|
||||
collapseAndVerifyState('.outline-item-section');
|
||||
expandAndVerifyState('.outline-item-section');
|
||||
collapseAndVerifyState('.outline-item-section');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Subsection", function() {
|
||||
it('can be deleted', function() {
|
||||
var promptSpy = view_helpers.createPromptSpy();
|
||||
createCourseOutlinePage(this, mockCourseJSON);
|
||||
getHeaderElement('.outline-item-subsection').find('.delete-button').click();
|
||||
view_helpers.confirmPrompt(promptSpy);
|
||||
create_sinon.expectJsonRequest(requests, 'DELETE', '/xblock/mock-subsection');
|
||||
create_sinon.respondWithJson(requests, {});
|
||||
// Note: verification of the server response and the UI's handling of it
|
||||
// is handled in the acceptance tests.
|
||||
create_sinon.expectJsonRequest(requests, 'GET', '/xblock/outline/mock-section');
|
||||
});
|
||||
|
||||
it('can add a unit', function() {
|
||||
var redirectSpy;
|
||||
createCourseOutlinePage(this, mockCourseJSON);
|
||||
redirectSpy = spyOn(ViewUtils, 'redirect');
|
||||
outlinePage.$('.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('can be renamed inline', function() {
|
||||
var updatedDisplayName = 'Updated Subsection Name',
|
||||
displayNameElement,
|
||||
subsectionModel;
|
||||
createCourseOutlinePage(this, mockCourseJSON);
|
||||
displayNameElement = getHeaderElement('.outline-item-subsection').find('.xblock-field-value');
|
||||
displayNameInput = view_helpers.inlineEdit(displayNameElement, updatedDisplayName);
|
||||
displayNameInput.change();
|
||||
// This is the response for the change operation.
|
||||
create_sinon.respondWithJson(requests, { });
|
||||
// This is the response for the subsequent fetch operation for the section.
|
||||
create_sinon.respondWithJson(requests,
|
||||
createMockSectionJSON('mock-section', 'Mock Section', [
|
||||
createMockSubsectionJSON('mock-subsection', updatedDisplayName, [])
|
||||
]));
|
||||
// Find the display name again in the refreshed DOM and verify it
|
||||
displayNameElement = getHeaderElement('.outline-item-subsection').find('.xblock-field-value');
|
||||
view_helpers.verifyInlineEditChange(displayNameElement, updatedDisplayName);
|
||||
subsectionModel = outlinePage.model.get('child_info').children[0].get('child_info').children[0];
|
||||
expect(subsectionModel.get('display_name')).toBe(updatedDisplayName);
|
||||
});
|
||||
|
||||
it('can be expanded and collapsed', function() {
|
||||
var subsectionElement;
|
||||
createCourseOutlinePage(this, mockCourseJSON);
|
||||
subsectionElement = outlinePage.$('.outline-item-subsection');
|
||||
expect(subsectionElement).toHaveClass('collapsed');
|
||||
expandAndVerifyState('.outline-item-subsection');
|
||||
collapseAndVerifyState('.outline-item-subsection');
|
||||
expandAndVerifyState('.outline-item-subsection');
|
||||
});
|
||||
});
|
||||
|
||||
// Note: most tests for units can be found in Bok Choy
|
||||
describe("Unit", function() {
|
||||
it('can be deleted', function() {
|
||||
var promptSpy = view_helpers.createPromptSpy();
|
||||
createCourseOutlinePage(this, mockCourseJSON);
|
||||
expandAndVerifyState('.outline-item-subsection');
|
||||
getHeaderElement('.outline-item-unit').find('.delete-button').click();
|
||||
view_helpers.confirmPrompt(promptSpy);
|
||||
create_sinon.expectJsonRequest(requests, 'DELETE', '/xblock/mock-unit');
|
||||
create_sinon.respondWithJson(requests, {});
|
||||
// Note: verification of the server response and the UI's handling of it
|
||||
// is handled in the acceptance tests.
|
||||
create_sinon.expectJsonRequest(requests, 'GET', '/xblock/outline/mock-section');
|
||||
});
|
||||
|
||||
it('has a link to the unit page', function() {
|
||||
var anchor;
|
||||
createCourseOutlinePage(this, mockCourseJSON);
|
||||
expandAndVerifyState('.outline-item-subsection');
|
||||
anchor = outlinePage.$('.outline-item-unit .xblock-title a');
|
||||
expect(anchor.attr('href')).toBe('/container/mock-unit');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
113
cms/static/js/spec/views/unit_outline_spec.js
Normal file
113
cms/static/js/spec/views/unit_outline_spec.js
Normal file
@@ -0,0 +1,113 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
44
cms/static/js/spec/views/utils/view_utils_spec.js
Normal file
44
cms/static/js/spec/views/utils/view_utils_spec.js
Normal file
@@ -0,0 +1,44 @@
|
||||
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);
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
/**
|
||||
* 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');
|
||||
@@ -70,11 +69,11 @@ define(['jquery', 'js/views/feedback_notification', 'js/views/feedback_prompt'],
|
||||
verifyNotificationHidden = function(notificationSpy) {
|
||||
verifyFeedbackHidden.apply(this, arguments);
|
||||
};
|
||||
|
||||
|
||||
createPromptSpy = function(type) {
|
||||
return createFeedbackSpy(Prompt, type || 'Warning');
|
||||
};
|
||||
|
||||
|
||||
confirmPrompt = function(promptSpy, pressSecondaryButton) {
|
||||
expect(promptSpy.constructor).toHaveBeenCalled();
|
||||
if (pressSecondaryButton) {
|
||||
@@ -91,6 +90,38 @@ define(['jquery', 'js/views/feedback_notification', 'js/views/feedback_prompt'],
|
||||
verifyPromptHidden = function(promptSpy) {
|
||||
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,
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* 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…'),
|
||||
_.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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
131
cms/static/js/views/course_outline.js
Normal file
131
cms/static/js/views/course_outline.js
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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…'),
|
||||
ViewUtils.runOperationShowingMessage(gettext('Saving…'),
|
||||
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');
|
||||
};
|
||||
|
||||
|
||||
50
cms/static/js/views/pages/base_page.js
Normal file
50
cms/static/js/views/pages/base_page.js
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 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…'),
|
||||
ViewUtils.runOperationShowingMessage(gettext('Duplicating…'),
|
||||
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,20 +203,13 @@ 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…'),
|
||||
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);
|
||||
});
|
||||
},
|
||||
|
||||
onDelete: function(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,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
},
|
||||
@@ -27,29 +26,6 @@ define(["jquery", "underscore", "gettext", "js/views/baseview"],
|
||||
render: function() {}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@@ -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…'),
|
||||
ViewUtils.runOperationShowingMessage(gettext('Publishing…'),
|
||||
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…'),
|
||||
ViewUtils.runOperationShowingMessage(gettext('Discarding Changes…'),
|
||||
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
|
||||
|
||||
72
cms/static/js/views/pages/course_outline.js
Normal file
72
cms/static/js/views/pages/course_outline.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 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?');
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
44
cms/static/js/views/unit_outline.js
Normal file
44
cms/static/js/views/unit_outline.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 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();
|
||||
160
cms/static/js/views/utils/view_utils.js
Normal file
160
cms/static/js/views/utils/view_utils.js
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
});
|
||||
91
cms/static/js/views/utils/xblock_utils.js
Normal file
91
cms/static/js/views/utils/xblock_utils.js
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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…'),
|
||||
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…'),
|
||||
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…'),
|
||||
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'),
|
||||
|
||||
252
cms/static/js/views/xblock_outline.js
Normal file
252
cms/static/js/views/xblock_outline.js
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* The XBlockOutlineView is used to render an xblock and its children based upon the information
|
||||
* provided in the XBlockInfo model. It is a recursive set of views where each XBlock has its own instance.
|
||||
*
|
||||
* The class provides several opportunities to override the default behavior in subclasses:
|
||||
* - shouldRenderChildren defaults to true meaning that the view should also create child views
|
||||
* - shouldExpandChildren defaults to true meaning that the view should show itself as expanded
|
||||
* - refresh is called when a server change has been made and the view needs to be refreshed
|
||||
*
|
||||
* The view can be constructed with an initialState option which is a JSON structure representing
|
||||
* the desired initial state. The parameters are as follows:
|
||||
* - expanded_locators - the locators that should be shown as expanded in addition to the defaults
|
||||
* - locator_to_show - the locator for the xblock which is the one being explicitly shown
|
||||
* - scroll_offset - the scroll offset to use for the locator being shown
|
||||
* - edit_display_name - true if the shown xblock's display name should be in inline edit mode
|
||||
*/
|
||||
define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/view_utils",
|
||||
"js/views/utils/xblock_utils", "js/views/xblock_string_field_editor"],
|
||||
function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, XBlockStringFieldEditor) {
|
||||
|
||||
var XBlockOutlineView = BaseView.extend({
|
||||
// takes XBlockInfo as a model
|
||||
|
||||
templateName: 'xblock-outline',
|
||||
|
||||
initialize: function() {
|
||||
BaseView.prototype.initialize.call(this);
|
||||
this.initialState = this.options.initialState;
|
||||
this.template = this.options.template;
|
||||
if (!this.template) {
|
||||
this.template = this.loadTemplate(this.templateName);
|
||||
}
|
||||
this.parentInfo = this.options.parentInfo;
|
||||
this.parentView = this.options.parentView;
|
||||
this.renderedChildren = false;
|
||||
this.model.on('sync', this.onXBlockChange, this);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
this.renderTemplate();
|
||||
this.addButtonActions(this.$el);
|
||||
this.addNameEditor();
|
||||
if (this.shouldRenderChildren() && this.shouldExpandChildren()) {
|
||||
this.renderChildren();
|
||||
}
|
||||
return this;
|
||||
},
|
||||
|
||||
renderTemplate: function() {
|
||||
var xblockInfo = this.model,
|
||||
childInfo = xblockInfo.get('child_info'),
|
||||
parentInfo = this.parentInfo,
|
||||
xblockType = this.getXBlockType(this.model.get('category'), this.parentInfo),
|
||||
parentType = parentInfo ? this.getXBlockType(parentInfo.get('category')) : null,
|
||||
addChildName = null,
|
||||
defaultNewChildName = null,
|
||||
html,
|
||||
isCollapsed = this.shouldRenderChildren() && !this.shouldExpandChildren();
|
||||
if (childInfo) {
|
||||
addChildName = interpolate(gettext('New %(component_type)s'), {
|
||||
component_type: childInfo.display_name
|
||||
}, true);
|
||||
defaultNewChildName = childInfo.display_name;
|
||||
}
|
||||
html = this.template({
|
||||
xblockInfo: xblockInfo,
|
||||
parentInfo: this.parentInfo,
|
||||
xblockType: xblockType,
|
||||
parentType: parentType,
|
||||
childType: childInfo ? this.getXBlockType(childInfo.category, xblockInfo) : null,
|
||||
childCategory: childInfo ? childInfo.category : null,
|
||||
addChildLabel: addChildName,
|
||||
defaultNewChildName: defaultNewChildName,
|
||||
isCollapsed: isCollapsed,
|
||||
includesChildren: this.shouldRenderChildren()
|
||||
});
|
||||
if (this.parentInfo) {
|
||||
this.setElement($(html));
|
||||
} else {
|
||||
this.$el.html(html);
|
||||
}
|
||||
},
|
||||
|
||||
renderChildren: function() {
|
||||
var self = this,
|
||||
xblockInfo = this.model;
|
||||
if (xblockInfo.get('child_info')) {
|
||||
_.each(this.model.get('child_info').children, function(child) {
|
||||
var childOutlineView = self.createChildView(child, xblockInfo);
|
||||
childOutlineView.render();
|
||||
self.addChildView(childOutlineView);
|
||||
});
|
||||
}
|
||||
this.renderedChildren = true;
|
||||
},
|
||||
|
||||
addChildView: function(childView) {
|
||||
this.$('> .sortable-list').append(childView.$el);
|
||||
},
|
||||
|
||||
addNameEditor: function() {
|
||||
var self = this,
|
||||
xblockField = this.$('.wrapper-xblock-field'),
|
||||
XBlockOutlineFieldEditor, nameEditor;
|
||||
if (xblockField.length > 0) {
|
||||
// Make a subclass of the standard xblock string field editor which refreshes
|
||||
// the entire section that this view is contained in. This is necessary as
|
||||
// changing the name could have caused the section to change state.
|
||||
XBlockOutlineFieldEditor = XBlockStringFieldEditor.extend({
|
||||
refresh: function() {
|
||||
self.refresh();
|
||||
}
|
||||
});
|
||||
nameEditor = new XBlockOutlineFieldEditor({
|
||||
el: xblockField,
|
||||
model: this.model
|
||||
});
|
||||
nameEditor.render();
|
||||
}
|
||||
},
|
||||
|
||||
toggleExpandCollapse: function(event) {
|
||||
// Ensure that the children have been rendered before expanding
|
||||
if (this.shouldRenderChildren() && !this.renderedChildren) {
|
||||
this.renderChildren();
|
||||
}
|
||||
BaseView.prototype.toggleExpandCollapse.call(this, event);
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds handlers to the each button in the header's panel. This is managed outside of
|
||||
* Backbone's own event registration so that the handlers don't get scoped to all the
|
||||
* children of this view.
|
||||
* @param element The root element of this view.
|
||||
*/
|
||||
addButtonActions: function(element) {
|
||||
var self = this;
|
||||
element.find('.delete-button').click(function(event) {
|
||||
event.preventDefault();
|
||||
self.deleteXBlock($(event.target));
|
||||
});
|
||||
element.find('.add-button').click(_.bind(this.handleAddEvent, this));
|
||||
},
|
||||
|
||||
shouldRenderChildren: function() {
|
||||
return true;
|
||||
},
|
||||
|
||||
shouldExpandChildren: function() {
|
||||
return true;
|
||||
},
|
||||
|
||||
createChildView: function(xblockInfo, parentInfo, parentView) {
|
||||
return new XBlockOutlineView({
|
||||
model: xblockInfo,
|
||||
parentInfo: parentInfo,
|
||||
initialState: this.initialState,
|
||||
template: this.template,
|
||||
parentView: parentView || this
|
||||
});
|
||||
},
|
||||
|
||||
getXBlockType: function(category, parentInfo) {
|
||||
var xblockType = category;
|
||||
if (category === 'chapter') {
|
||||
xblockType = 'section';
|
||||
} else if (category === 'sequential') {
|
||||
xblockType = 'subsection';
|
||||
} else if (category === 'vertical' && parentInfo && parentInfo.get('category') === 'sequential') {
|
||||
xblockType = 'unit';
|
||||
}
|
||||
return xblockType;
|
||||
},
|
||||
|
||||
onXBlockChange: function() {
|
||||
var oldElement = this.$el,
|
||||
viewState = this.initialState;
|
||||
this.render();
|
||||
if (this.parentInfo) {
|
||||
oldElement.replaceWith(this.$el);
|
||||
}
|
||||
if (viewState) {
|
||||
this.setViewState(viewState);
|
||||
}
|
||||
},
|
||||
|
||||
setViewState: function(viewState) {
|
||||
var locatorToShow = viewState.locator_to_show,
|
||||
scrollOffset = viewState.scroll_offset || 0,
|
||||
editDisplayName = viewState.edit_display_name,
|
||||
locatorElement;
|
||||
if (locatorToShow) {
|
||||
if (locatorToShow === this.model.id) {
|
||||
locatorElement = this.$el;
|
||||
} else {
|
||||
locatorElement = this.$('.outline-item[data-locator="' + locatorToShow + '"]');
|
||||
}
|
||||
ViewUtils.setScrollOffset(locatorElement, scrollOffset);
|
||||
if (editDisplayName) {
|
||||
locatorElement.find('> .wrapper-xblock-header .xblock-field-value').click();
|
||||
}
|
||||
}
|
||||
this.initialState = null;
|
||||
},
|
||||
|
||||
onChildAdded: function(locator, category) {
|
||||
// For units, redirect to the new page, and for everything else just refresh inline.
|
||||
if (category === 'vertical') {
|
||||
this.onUnitAdded(locator);
|
||||
} else {
|
||||
this.refresh();
|
||||
}
|
||||
},
|
||||
|
||||
onUnitAdded: function(locator) {
|
||||
ViewUtils.redirect('/container/' + locator + '?action=new');
|
||||
},
|
||||
|
||||
onChildDeleted: function() {
|
||||
this.refresh();
|
||||
},
|
||||
|
||||
deleteXBlock: function() {
|
||||
var parentView = this.parentView;
|
||||
XBlockViewUtils.deleteXBlock(this.model).done(function() {
|
||||
if (parentView) {
|
||||
parentView.onChildDeleted();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh the view's model from the server, which will cause the view to refresh.
|
||||
* @returns {jQuery promise} A promise representing the refresh operation.
|
||||
*/
|
||||
refresh: function() {
|
||||
return this.model.fetch();
|
||||
},
|
||||
|
||||
handleAddEvent: function(event) {
|
||||
var self = this,
|
||||
target = $(event.target),
|
||||
category = target.data('category');
|
||||
event.preventDefault();
|
||||
XBlockViewUtils.addXBlock(target).done(function(locator) {
|
||||
self.onChildAdded(locator, category, event);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return XBlockOutlineView;
|
||||
}); // end define();
|
||||
@@ -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,30 +73,26 @@ 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…'),
|
||||
function() {
|
||||
return xblockInfo.save(requestData);
|
||||
}).done(function() {
|
||||
// Update publish and last modified information from the server.
|
||||
xblockInfo.fetch();
|
||||
});
|
||||
},
|
||||
|
||||
createUpdateRequestData: function(newValue) {
|
||||
var metadata = {};
|
||||
metadata[this.fieldName] = newValue;
|
||||
return {
|
||||
metadata: metadata
|
||||
};
|
||||
return XBlockViewUtils.updateXBlockField(xblockInfo, this.fieldName, newValue).done(function() {
|
||||
self.refresh();
|
||||
});
|
||||
},
|
||||
|
||||
handleKeyUp: function(event) {
|
||||
|
||||
@@ -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>
|
||||
${units.enum_units(subsection, actions=False, selected=unit.location)}
|
||||
</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>
|
||||
<div class="wrapper-unit-overview">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
% endif
|
||||
|
||||
93
cms/templates/course_outline.html
Normal file
93
cms/templates/course_outline.html
Normal file
@@ -0,0 +1,93 @@
|
||||
<%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">> </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>
|
||||
81
cms/templates/js/course-outline.underscore
Normal file
81
cms/templates/js/course-outline.underscore
Normal file
@@ -0,0 +1,81 @@
|
||||
<% 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>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>
|
||||
<div class="wrapper-unit-overview">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
62
cms/templates/js/mock/mock-course-outline-page.underscore
Normal file
62
cms/templates/js/mock/mock-course-outline-page.underscore
Normal file
@@ -0,0 +1,62 @@
|
||||
<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">> </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>
|
||||
27
cms/templates/js/unit-outline.underscore
Normal file
27
cms/templates/js/unit-outline.underscore
Normal file
@@ -0,0 +1,27 @@
|
||||
<% 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>
|
||||
<% } %>
|
||||
82
cms/templates/js/xblock-outline.underscore
Normal file
82
cms/templates/js/xblock-outline.underscore
Normal file
@@ -0,0 +1,82 @@
|
||||
<% 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>
|
||||
<% } %>
|
||||
@@ -1,3 +1,4 @@
|
||||
<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>
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
<%inherit file="base.html" />
|
||||
<%def name="online_help_token()"><% return "outline" %></%def>
|
||||
<%!
|
||||
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'/>
|
||||
<%namespace name="units" file="widgets/units.html" />
|
||||
|
||||
<%block name="jsextra">
|
||||
<link rel="stylesheet" type="text/css" href="${static.url('js/vendor/timepicker/jquery.timepicker.css')}" />
|
||||
<link rel="stylesheet" type="text/css" href="${static.url('css/vendor/html5-input-polyfills/number-polyfill.css')}" />
|
||||
<script type="text/template" id="section-name-edit-tpl">
|
||||
<%static:include path="js/section-name-edit.underscore" />
|
||||
</script>
|
||||
|
||||
<script type="text/javascript">
|
||||
require(["domReady!", "jquery", "js/models/location", "js/models/section", "js/views/section_show", "js/views/overview_assignment_grader", "js/collections/course_grader", "js/views/overview", "jquery.inputnumber"],
|
||||
function(doc, $, Location, SectionModel, SectionShowView, OverviewAssignmentGrader, CourseGraderCollection){
|
||||
// TODO figure out whether these should be in window or someplace else or whether they're only needed as local vars
|
||||
// I believe that current (New Section/New Subsection) cause full page reloads which means these aren't needed globally
|
||||
// but we really should change that behavior.
|
||||
if (!window.graderTypes) {
|
||||
window.graderTypes = new CourseGraderCollection(${course_graders|n}, {parse:true});
|
||||
}
|
||||
|
||||
$(".gradable-status").each(function(index, ele) {
|
||||
var gradeView = new OverviewAssignmentGrader({
|
||||
el : ele,
|
||||
graders : window.graderTypes
|
||||
});
|
||||
});
|
||||
|
||||
$(".section-name.is_editable").each(function() {
|
||||
var model = new SectionModel({
|
||||
id: $(this).parent(".item-details").data("locator"),
|
||||
name: $(this).data("name")
|
||||
});
|
||||
new SectionShowView({model: model, el: this}).render();
|
||||
})
|
||||
});
|
||||
</script>
|
||||
</%block>
|
||||
|
||||
<%block name="header_extras">
|
||||
<script type="text/template" id="new-section-template">
|
||||
<section class="courseware-section new-section is-collapsible is-draggable">
|
||||
<header class="section">
|
||||
<a href="#" data-tooltip="${_('Expand/collapse this section')}" class="action expand-collapse collapse"><i class="icon-caret-down ui-toggle-expansion"></i><span class="sr">${_('Expand/collapse this section')}</span></a>
|
||||
<div class="item-details">
|
||||
<h3 class="section-name is_editable">
|
||||
<form class="section-name-form">
|
||||
<input type="text" value="${_('New Section Name')}" class="new-section-name" />
|
||||
<input type="submit" class="new-section-name-save" data-parent="${context_course.location}"
|
||||
data-category="${new_section_category}" value="${_('Save')}" />
|
||||
<input type="button" class="new-section-name-cancel" value="${_('Cancel')}" />
|
||||
</form>
|
||||
</h3>
|
||||
</div>
|
||||
</header>
|
||||
</section>
|
||||
</script>
|
||||
|
||||
|
||||
<script type="text/template" id="blank-slate-template">
|
||||
<section class="courseware-section new-section">
|
||||
<header>
|
||||
<a href="#" data-tooltip="${_('Expand/collapse this section')}" class="action expand-collapse"><i class="icon-caret-down ui-toggle-dd"></i><span class="sr">${_('Expand/collapse this section')}</span></a>
|
||||
<div class="item-details">
|
||||
<h3 class="section-name is_editable">
|
||||
<span class="section-name-span">${_('Add a new section name')}</span>
|
||||
<form class="section-name-form">
|
||||
<input type="text" value="${_('New Section Name')}" class="new-section-name" />
|
||||
<input type="submit" class="new-section-name-save" data-parent="${context_course.id}"
|
||||
data-category="${new_section_category}" value="${_('Save')}" />
|
||||
<input type="button" class="new-section-name-cancel" value="$(_('Cancel')}" /></h3>
|
||||
</form>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<a href="#" data-tooltip="${_('Delete this section')}" class="delete-button delete-section-button"><span class="delete-icon"></span></a>
|
||||
<span data-tooltip="${_('Drag to re-order')}" class="drag-handle action"></span>
|
||||
</div>
|
||||
</header>
|
||||
</section>
|
||||
</script>
|
||||
|
||||
<script type="text/template" id="new-subsection-template">
|
||||
<li class="courseware-subsection collapsed is-draggable is-collapsible">
|
||||
<div class="section-item editing">
|
||||
<form class="new-subsection-form">
|
||||
<span class="subsection-name">
|
||||
<input type="text" value="${_('New Subsection')}" class="new-subsection-name-input" />
|
||||
</span>
|
||||
<input type="submit" value="${_('Save')}" class="new-subsection-name-save" />
|
||||
<input type="button" value="${_('Cancel')}" class="new-subsection-name-cancel" />
|
||||
</form>
|
||||
</div>
|
||||
<ol>
|
||||
<li>
|
||||
<a href="unit.html" class="new-unit-item">
|
||||
<i class="icon-plus"></i> ${_('New Unit')}
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
</li>
|
||||
</script>
|
||||
|
||||
<script type="text/template" id="no-outline-content">
|
||||
<div class="no-outline-content">
|
||||
<p>${_("You haven't added any sections to your course outline yet.")} <a href="#" class="button new-button"><i class="icon-plus"></i>${_("Add your first section")}</a></p>
|
||||
</div>
|
||||
</script>
|
||||
</%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">> </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-sections"><i class="icon-arrow-up"></i> <span class="label">${_("Collapse All Sections")}</span></a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="#" class="button new-button new-courseware-section-button"><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="courseware-overview" data-locator="${course_locator}" data-course-key="${course_locator.course_key}">
|
||||
% for section in sections:
|
||||
<%
|
||||
section_locator = section.location
|
||||
%>
|
||||
<section class="courseware-section is-collapsible is-draggable" data-parent="${course_locator}"
|
||||
data-locator="${section_locator}">
|
||||
<%include file="widgets/_ui-dnd-indicator-before.html" />
|
||||
|
||||
<header class="section">
|
||||
<a href="#" data-tooltip="${_('Expand/collapse this section')}" class="action expand-collapse collapse"><i class="icon-caret-down ui-toggle-expansion"></i><span class="sr">${_('Expand/collapse this section')}</span></a>
|
||||
|
||||
<div class="item-details" data-locator="${section_locator}">
|
||||
<h3 class="section-name is_editable" data-name="${section.display_name_with_default | h}"></h3>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
|
||||
<ul class="actions-list">
|
||||
|
||||
<li class="actions-item pubdate">
|
||||
<div class="section-published-date">
|
||||
<%
|
||||
if section.start is not None:
|
||||
start_date_str = section.start.strftime('%m/%d/%Y')
|
||||
start_time_str = section.start.strftime('%H:%M')
|
||||
else:
|
||||
start_date_str = ''
|
||||
start_time_str = ''
|
||||
%>
|
||||
%if section.start is None:
|
||||
<span class="published-status">${_("This section is not scheduled for release")}</span>
|
||||
<a href="#" class="edit-release-date action" data-date="" data-time="" data-locator="${section_locator}"><i class="icon-time"></i> <span class="sr">${_("Schedule")}</span></a>
|
||||
%else:
|
||||
<span class="published-status"><strong>${_("Release date:")}</strong>
|
||||
${get_default_time_display(section.start)}</span>
|
||||
<a href="#" class="edit-release-date action" data-date="${start_date_str}" data-time="${start_time_str}" data-locator="${section_locator}"><i class="icon-time"></i> <span class="sr">${_("Edit section release date")}</span></a>
|
||||
%endif
|
||||
</div>
|
||||
</li>
|
||||
<li class="actions-item delete">
|
||||
<a href="#" data-tooltip="${_('Delete this section')}" class="action delete-section-button"><i class="icon-trash"></i> <span class="sr">${_('Delete section')}</span></a>
|
||||
</li>
|
||||
<li class="actions-item drag">
|
||||
<span data-tooltip="${_('Drag to reorder')}" class="drag-handle section-drag-handle"><span class="sr"> ${_("Drag to reorder section")}</span></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
<div class="subsection-list">
|
||||
|
||||
<ol class="sortable-subsection-list">
|
||||
% for subsection in section.get_children():
|
||||
<%
|
||||
subsection_locator = subsection.location
|
||||
%>
|
||||
<li class="courseware-subsection collapsed id-holder is-draggable is-collapsible "
|
||||
data-parent="${section_locator}" data-locator="${subsection_locator}">
|
||||
|
||||
<%include file="widgets/_ui-dnd-indicator-before.html" />
|
||||
|
||||
<div class="section-item">
|
||||
<div class="details">
|
||||
<a href="#" data-tooltip="${_('Expand/collapse this subsection')}" class="action expand-collapse expand"><i class="icon-caret-down ui-toggle-expansion"></i><span class="sr">${_('Expand/collapse this subsection')}</span></a>
|
||||
<a href="${reverse_usage_url('subsection_handler', subsection_locator)}">
|
||||
<span class="subsection-name"><span class="subsection-name-value">${subsection.display_name_with_default}</span></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="item-actions">
|
||||
|
||||
<ul class="actions-list">
|
||||
<li class="actions-item grades">
|
||||
<div class="gradable-status" data-initial-status="${subsection.format if subsection.format is not None else 'notgraded'}"></div>
|
||||
</li>
|
||||
<li class="actions-item delete">
|
||||
<a href="#" data-tooltip="${_('Delete this subsection')}" class="action delete-subsection-button"><i class="icon-trash"></i> <span class="sr">${_("Delete subsection")}</span></a>
|
||||
</li>
|
||||
<li class="actions-item drag">
|
||||
<span data-tooltip="${_('Drag to reorder')}" class="drag-handle subsection-drag-handle"></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
${units.enum_units(subsection)}
|
||||
|
||||
<%include file="widgets/_ui-dnd-indicator-after.html" />
|
||||
</li>
|
||||
% endfor
|
||||
<li class="ui-splint ui-splint-indicator">
|
||||
<%include file="widgets/_ui-dnd-indicator-initial.html" />
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="list-header new-subsection">
|
||||
<a href="#" class="new-subsection-item" data-category="${new_subsection_category}">
|
||||
<i class="icon-plus"></i> ${_("New Subsection")}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<%include file="widgets/_ui-dnd-indicator-after.html" />
|
||||
</section>
|
||||
% endfor
|
||||
</article>
|
||||
</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
|
||||
class="wrapper wrapper-modal-window wrapper-dialog-edit-sectionrelease edit-section-publish-settings"
|
||||
aria-describedby="dialog-edit-sectionrelease-description"
|
||||
aria-labelledby="dialog-edit-sectionrelease-title"
|
||||
aria-hidden=""
|
||||
role="dialog">
|
||||
<div class="modal-window modal-med confirm">
|
||||
<form class="edit-sectionrelease-dialog" action="#">
|
||||
<div class="modal-content">
|
||||
<h2 class="title dialog-edit-sectionrelease-title">${_("Section Release Date")}</h2>
|
||||
<p id="dialog-edit-sectionrelease-description" class="message">${_('On the date set below, this section - {name} - will be released to students. Any units marked private will only be visible to admins.').format(name='<strong class="section-name"></strong>')}</p>
|
||||
<ul class="list-input picker datepair">
|
||||
<li class="field field-start-date">
|
||||
<label for="start_date">${_("Release Day")}</label>
|
||||
<input class="start-date date" type="text" name="start_date" value="" placeholder="MM/DD/YYYY" class="date" size='15' autocomplete="off"/>
|
||||
</li>
|
||||
<li class="field field-start-time">
|
||||
<label for="start_time">${_("Release Time")} in <abbr title="${_("Coordinated Universal Time")}">UTC</abbr></label>
|
||||
<input class="start-time time" type="text" name="start_time" value="" placeholder="HH:MM" class="time" size='10' autocomplete="off"/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="actions modal-actions">
|
||||
<h3 class="sr">${_("Form Actions")}</h3>
|
||||
<ul>
|
||||
<li class="action-item">
|
||||
<a href="#" class="button action-primary action-save">${_("Save")}</a>
|
||||
</li>
|
||||
<li class="action-item">
|
||||
<a href="#" class="button action-secondary action-cancel">${_("Cancel")}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</%block>
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
def child_at(self, index, child_class=None):
|
||||
"""
|
||||
Returns the child at the specified index.
|
||||
:type self: object
|
||||
"""
|
||||
if not child_class:
|
||||
child_class = self.CHILD_CLASS
|
||||
|
||||
class CourseOutlineChild(PageObject):
|
||||
"""
|
||||
A mixin to a CourseOutline page object that will be used as a child of
|
||||
:class:`CourseOutlineContainer`.
|
||||
"""
|
||||
NAME_SELECTOR = None
|
||||
BODY_SELECTOR = None
|
||||
return child_class(
|
||||
self.browser,
|
||||
self.q(css=child_class.BODY_SELECTOR).attrs('data-locator')[index]
|
||||
)
|
||||
|
||||
|
||||
class CourseOutlineChild(PageObject, CourseOutlineItem):
|
||||
"""
|
||||
A page object that will be used as a child of :class:`CourseOutlineContainer`.
|
||||
"""
|
||||
def __init__(self, browser, locator):
|
||||
super(CourseOutlineChild, self).__init__(browser)
|
||||
self.locator = locator
|
||||
@@ -49,38 +100,14 @@ class CourseOutlineChild(PageObject):
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='{}[data-locator="{}"]'.format(self.BODY_SELECTOR, self.locator)).present
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""
|
||||
Return the display name of this object.
|
||||
"""
|
||||
titles = self.q(css=self._bounded_selector(self.NAME_SELECTOR)).text
|
||||
if titles:
|
||||
return titles[0]
|
||||
else:
|
||||
return None
|
||||
|
||||
def __repr__(self):
|
||||
return "{}(<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):
|
||||
"""
|
||||
@@ -88,7 +115,7 @@ class CourseOutlineUnit(CourseOutlineChild):
|
||||
an initialized :class:`.ContainerPage` for that unit.
|
||||
"""
|
||||
return ContainerPage(self.browser, self.locator).visit()
|
||||
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css=self.BODY_SELECTOR).present
|
||||
|
||||
@@ -99,8 +126,7 @@ class CourseOutlineSubsection(CourseOutlineChild, CourseOutlineContainer):
|
||||
"""
|
||||
url = None
|
||||
|
||||
BODY_SELECTOR = '.courseware-subsection'
|
||||
NAME_SELECTOR = '.subsection-name-value'
|
||||
BODY_SELECTOR = '.outline-item-subsection'
|
||||
CHILD_CLASS = CourseOutlineUnit
|
||||
|
||||
def unit(self, title):
|
||||
@@ -116,15 +142,12 @@ class CourseOutlineSubsection(CourseOutlineChild, CourseOutlineContainer):
|
||||
self.browser.execute_script("jQuery.fx.off = true;")
|
||||
|
||||
def subsection_expanded():
|
||||
return all(
|
||||
self.q(css=self._bounded_selector('.new-unit-item'))
|
||||
.map(lambda el: el.is_displayed())
|
||||
.results
|
||||
)
|
||||
add_button = self.q(css=self._bounded_selector('.add-button')).first.results
|
||||
return add_button and add_button[0].is_displayed()
|
||||
|
||||
currently_expanded = subsection_expanded()
|
||||
|
||||
self.q(css=self._bounded_selector('.expand-collapse')).first.click()
|
||||
self.q(css=self._bounded_selector('.ui-toggle-expansion')).first.click()
|
||||
|
||||
EmptyPromise(
|
||||
lambda: subsection_expanded() != currently_expanded,
|
||||
@@ -139,8 +162,7 @@ class CourseOutlineSection(CourseOutlineChild, CourseOutlineContainer):
|
||||
:class`.PageObject` that wraps a section block on the Studio Course Overview page.
|
||||
"""
|
||||
url = None
|
||||
BODY_SELECTOR = '.courseware-section'
|
||||
NAME_SELECTOR = '.section-name-span'
|
||||
BODY_SELECTOR = '.outline-item-section'
|
||||
CHILD_CLASS = CourseOutlineSubsection
|
||||
|
||||
def subsection(self, title):
|
||||
@@ -165,6 +187,12 @@ class CourseOutlinePage(CoursePage, CourseOutlineContainer):
|
||||
Return the :class:`.CourseOutlineSection` with the title `title`.
|
||||
"""
|
||||
return self.child(title)
|
||||
|
||||
def section_at(self, index):
|
||||
"""
|
||||
Returns the :class:`.CourseOutlineSection` at the specified index.
|
||||
"""
|
||||
return self.child_at(index)
|
||||
|
||||
def click_section_name(self, parent_css=''):
|
||||
"""
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"""
|
||||
Utility methods useful for Studio page tests.
|
||||
"""
|
||||
from selenium.webdriver.common.action_chains import ActionChains
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from bok_choy.promise import EmptyPromise
|
||||
|
||||
from ...tests.helpers import disable_animations
|
||||
|
||||
|
||||
@@ -122,3 +125,17 @@ def confirm_prompt(page, cancel=False):
|
||||
confirmation_button_css = '.prompt .action-' + ('secondary' if cancel else 'primary')
|
||||
page.wait_for_element_visibility(confirmation_button_css, 'Confirmation button is visible')
|
||||
click_css(page, confirmation_button_css, require_notification=(not cancel))
|
||||
|
||||
|
||||
def set_input_value_and_save(page, css, value):
|
||||
"""
|
||||
Sets the text field with given label (display name) to the specified value, and presses Save.
|
||||
"""
|
||||
input_element = page.q(css=css).results[0]
|
||||
# Click in the input to give it the focus
|
||||
action = ActionChains(page.browser).click(input_element)
|
||||
# Delete all of the characters that are currently there
|
||||
for _x in range(0, len(input_element.get_attribute('value'))):
|
||||
action = action.send_keys(Keys.BACKSPACE)
|
||||
# Send the new text, then hit the enter key so that the change event is triggered).
|
||||
action.send_keys(value).send_keys(Keys.ENTER).perform()
|
||||
|
||||
@@ -142,23 +142,25 @@ class CourseSectionTest(StudioCourseTest):
|
||||
"""
|
||||
Check that section name is editable on course outline page.
|
||||
"""
|
||||
section_name = self.course_outline_page.get_section_name()[0]
|
||||
self.assertEqual(section_name, "Test Section")
|
||||
self.course_outline_page.change_section_name("Test Section New")
|
||||
section_name = self.course_outline_page.get_section_name(page_refresh=True)[0]
|
||||
self.assertEqual(section_name, "Test Section New")
|
||||
new_name = u"Test Section New"
|
||||
section = self.course_outline_page.section_at(0)
|
||||
self.assertEqual(section.name, u"Test Section")
|
||||
section.change_name(new_name)
|
||||
self.browser.refresh()
|
||||
self.assertEqual(section.name, new_name)
|
||||
|
||||
def test_section_name_not_editable_inside_modal(self):
|
||||
"""
|
||||
Check that section name is not editable inside "Section Release Date" modal on course outline page.
|
||||
"""
|
||||
parent_css='div.modal-window'
|
||||
self.course_outline_page.click_release_date()
|
||||
section_name = self.course_outline_page.get_section_name(parent_css)[0]
|
||||
self.assertEqual(section_name, '"Test Section"')
|
||||
self.course_outline_page.click_section_name(parent_css)
|
||||
section_name_edit_form = self.course_outline_page.section_name_edit_form_present(parent_css)
|
||||
self.assertFalse(section_name_edit_form)
|
||||
# TODO: re-enable when release date support is added back
|
||||
# def test_section_name_not_editable_inside_modal(self):
|
||||
# """
|
||||
# Check that section name is not editable inside "Section Release Date" modal on course outline page.
|
||||
# """
|
||||
# parent_css='div.modal-window'
|
||||
# self.course_outline_page.click_release_date()
|
||||
# section_name = self.course_outline_page.get_section_name(parent_css)[0]
|
||||
# self.assertEqual(section_name, '"Test Section"')
|
||||
# self.course_outline_page.click_section_name(parent_css)
|
||||
# section_name_edit_form = self.course_outline_page.section_name_edit_form_present(parent_css)
|
||||
# self.assertFalse(section_name_edit_form)
|
||||
|
||||
|
||||
@attr('shard_1')
|
||||
|
||||
Reference in New Issue
Block a user