Merge pull request #4296 from edx/bulk-publishing
Studio Publishing redesign
This commit is contained in:
@@ -5,7 +5,11 @@ These are notable changes in edx-platform. This is a rolling list of changes,
|
||||
in roughly chronological order, most recent first. Add your entries at or near
|
||||
the top. Include a label indicating the component affected.
|
||||
|
||||
LMS: Instructors can request and see content of previous bulk emails sent in the instructor dashboard.
|
||||
LMS: Instructors can request and see content of previous bulk emails sent in the instructor dashboard.
|
||||
|
||||
Studio: New course outline and unit/container pages with revised publishing model. STUD-1790 (part 1)
|
||||
|
||||
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('.outline .button-new')
|
||||
assert_true(world.is_css_present('.outline-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-subsection .expand-collapse', '.outline-subsection .button-new'
|
||||
]
|
||||
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')
|
||||
@@ -397,27 +380,3 @@ def create_other_user(_step, name, has_extra_perms, role_name):
|
||||
def log_out(_step):
|
||||
world.visit('logout')
|
||||
|
||||
|
||||
@step(u'I click on "edit a draft"$')
|
||||
def i_edit_a_draft(_step):
|
||||
world.css_click("a.create-draft")
|
||||
|
||||
|
||||
@step(u'I click on "replace with draft"$')
|
||||
def i_replace_w_draft(_step):
|
||||
world.css_click("a.publish-draft")
|
||||
|
||||
|
||||
@step(u'I click on "delete draft"$')
|
||||
def i_delete_draft(_step):
|
||||
world.css_click("a.delete-draft")
|
||||
|
||||
|
||||
@step(u'I publish the unit$')
|
||||
def publish_unit(_step):
|
||||
world.select_option('visibility-select', 'public')
|
||||
|
||||
|
||||
@step(u'I unpublish the unit$')
|
||||
def unpublish_unit(_step):
|
||||
world.select_option('visibility-select', 'private')
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
# pylint: disable=W0613
|
||||
|
||||
from lettuce import world, step
|
||||
from nose.tools import assert_true, assert_in # pylint: disable=E0611
|
||||
from nose.tools import assert_true, assert_in, assert_equal # pylint: disable=E0611
|
||||
|
||||
DISPLAY_NAME = "Display Name"
|
||||
|
||||
@@ -48,7 +48,7 @@ def add_a_multi_step_component(step, is_advanced, category):
|
||||
def see_a_multi_step_component(step, category):
|
||||
|
||||
# Wait for all components to finish rendering
|
||||
selector = 'li.component div.xblock-student_view'
|
||||
selector = 'li.studio-xblock-wrapper div.xblock-student_view'
|
||||
world.wait_for(lambda _: len(world.css_find(selector)) == len(step.hashes))
|
||||
|
||||
for idx, step_hash in enumerate(step.hashes):
|
||||
@@ -79,7 +79,7 @@ def see_a_problem_component(step, category):
|
||||
assert_true(world.is_css_present(component_css),
|
||||
'No problem was added to the unit.')
|
||||
|
||||
problem_css = 'li.component div.xblock-student_view'
|
||||
problem_css = 'li.studio-xblock-wrapper div.xblock-student_view'
|
||||
actual_text = world.css_text(problem_css)
|
||||
assert_in(category.upper(), actual_text)
|
||||
|
||||
@@ -93,7 +93,7 @@ def add_component_category(step, component, category):
|
||||
|
||||
@step(u'I delete all components$')
|
||||
def delete_all_components(step):
|
||||
count = len(world.css_find('ol.components li.component'))
|
||||
count = len(world.css_find('ol.reorderable-container li.studio-xblock-wrapper'))
|
||||
step.given('I delete "' + str(count) + '" component')
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ def delete_components(step, number):
|
||||
|
||||
@step(u'I see no components')
|
||||
def see_no_components(steps):
|
||||
assert world.is_css_not_present('li.component')
|
||||
assert world.is_css_not_present('li.studio-xblock-wrapper')
|
||||
|
||||
|
||||
@step(u'I delete a component')
|
||||
@@ -162,8 +162,9 @@ def see_component_in_position(step, display_name, index):
|
||||
|
||||
@step(u'I see the display name is "([^"]*)"')
|
||||
def check_component_display_name(step, display_name):
|
||||
label = world.css_text(".component-header")
|
||||
assert display_name == label
|
||||
# The display name for the unit uses the same structure, must differentiate by level-element.
|
||||
label = world.css_html("section.level-element>header>div>div>span.xblock-display-name")
|
||||
assert_equal(display_name, label)
|
||||
|
||||
|
||||
@step(u'I change the display name to "([^"]*)"')
|
||||
|
||||
@@ -122,9 +122,9 @@ def ensure_settings_visible():
|
||||
|
||||
|
||||
@world.absorb
|
||||
def edit_component():
|
||||
def edit_component(index=0):
|
||||
world.wait_for(lambda _driver: world.css_visible('a.edit-button'))
|
||||
world.css_click('a.edit-button')
|
||||
world.css_click('a.edit-button', index)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ def i_click_on_error_dialog(step):
|
||||
# we don't know the actual ID of the vertical. So just check that we did go to a
|
||||
# vertical page in the course (there should only be one).
|
||||
vertical_usage_key = course_key.make_usage_key("vertical", None)
|
||||
vertical_url = reverse_usage_url('unit_handler', vertical_usage_key)
|
||||
vertical_url = reverse_usage_url('container_handler', vertical_usage_key)
|
||||
# Remove the trailing "/None" from the URL - we don't know the course ID, so we just want to
|
||||
# check that we visited a vertical URL.
|
||||
if vertical_url.endswith("/None"):
|
||||
|
||||
@@ -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,89 +48,86 @@ 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-section > .section-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 = '.button-toggle-expand-collapse .collapse-all .label'
|
||||
elif text == "Expand":
|
||||
span_locator = '.button-toggle-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 = '.button-toggle-expand-collapse .collapse-all .label'
|
||||
elif text == "Expand":
|
||||
span_locator = '.button-toggle-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 = '.button-toggle-expand-collapse .collapse-all .label'
|
||||
elif text == "Expand":
|
||||
span_locator = '.button-toggle-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-section .ui-toggle-expansion'
|
||||
elif text == "expand":
|
||||
locator = 'section .outline-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")
|
||||
def change_grading_status(step):
|
||||
world.css_find('a.menu-toggle').click()
|
||||
world.css_find('.menu li').first.click()
|
||||
|
||||
|
||||
@step(u'I reorder subsections')
|
||||
def reorder_subsections(_step):
|
||||
draggable_css = '.subsection-drag-handle'
|
||||
ele = world.css_find(draggable_css).first
|
||||
ele.action_chains.drag_and_drop_by_offset(
|
||||
ele._element,
|
||||
0,
|
||||
25
|
||||
).perform()
|
||||
@@ -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 = '.outline .button-new'
|
||||
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)
|
||||
|
||||
@@ -12,7 +12,7 @@ Feature: CMS.Help
|
||||
Given I have opened a new course in Studio
|
||||
|
||||
And I click the course link in My Courses
|
||||
Then I should see online help for "organizing_course"
|
||||
Then I should see online help for "outline"
|
||||
|
||||
And I go to the course updates page
|
||||
Then I should see online help for "updates"
|
||||
@@ -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,
|
||||
|
||||
@@ -81,38 +81,6 @@ Feature: CMS.Problem Editor
|
||||
When I edit and select Settings
|
||||
Then Edit High Level Source is visible
|
||||
|
||||
# This is a very specific scenario that was failing with some of the
|
||||
# DB rearchitecture changes. It had to do with children IDs being stored
|
||||
# with @draft at the end. To reproduce, must update children while in draft mode.
|
||||
Scenario: Problems can be deleted after being public
|
||||
Given I have created a Blank Common Problem
|
||||
And I have created another Blank Common Problem
|
||||
When I publish the unit
|
||||
And I click on "edit a draft"
|
||||
And I delete "1" component
|
||||
And I click on "replace with draft"
|
||||
And I click on "edit a draft"
|
||||
And I delete "1" component
|
||||
Then I see no components
|
||||
|
||||
# This is a very specific scenario for a bug where editing a component in draft
|
||||
# impacted the published version.
|
||||
Scenario: Changes to draft problem do not impact published version
|
||||
Given I have created a Blank Common Problem
|
||||
When I publish the unit
|
||||
And I click on "edit a draft"
|
||||
And I change the display name to "draft"
|
||||
And I click on "delete draft"
|
||||
Then the problem display name is "Blank Common Problem"
|
||||
|
||||
Scenario: Problems can be made private after being made public
|
||||
Given I have created a Blank Common Problem
|
||||
When I publish the unit
|
||||
And I click on "edit a draft"
|
||||
And I click on "delete draft"
|
||||
And I unpublish the unit
|
||||
Then I can edit the problem
|
||||
|
||||
Scenario: Cheat sheet visible on toggle
|
||||
Given I have created a Blank Common Problem
|
||||
And I can edit the problem
|
||||
|
||||
@@ -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,
|
||||
@@ -305,15 +305,13 @@ def i_can_edit_problem(_step):
|
||||
|
||||
@step(u'I edit first blank advanced problem for annotation response$')
|
||||
def i_edit_blank_problem_for_annotation_response(_step):
|
||||
edit_css = """$('.component-header:contains("Blank Advanced Problem")').parent().find('a.edit-button').click()"""
|
||||
world.edit_component(1)
|
||||
text = """
|
||||
<problem>
|
||||
<annotationresponse>
|
||||
<annotationinput><text>Text of annotation</text></annotationinput>
|
||||
</annotationresponse>
|
||||
</problem>"""
|
||||
world.browser.execute_script(edit_css)
|
||||
world.wait_for_ajax_complete()
|
||||
type_in_codemirror(0, text)
|
||||
world.save_component()
|
||||
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
@@ -152,6 +151,7 @@ def xml_only_video(step):
|
||||
category='video',
|
||||
data='<video youtube="1.00:%s"></video>' % youtube_id,
|
||||
modulestore=store,
|
||||
user_id=world.scenario_dict["USER"].id
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase):
|
||||
|
||||
# just pick one vertical
|
||||
descriptor = store.get_items(course.id, category='vertical',)
|
||||
resp = self.client.get_html(get_url('unit_handler', descriptor[0].location))
|
||||
resp = self.client.get_html(get_url('container_handler', descriptor[0].location))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
for expected in expected_types:
|
||||
@@ -120,7 +120,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase):
|
||||
# just pick one vertical
|
||||
usage_key = course_items[0].id.make_usage_key('vertical', None)
|
||||
|
||||
resp = self.client.get_html(get_url('unit_handler', usage_key))
|
||||
resp = self.client.get_html(get_url('container_handler', usage_key))
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
def check_edit_unit(self, test_course_name):
|
||||
@@ -926,7 +926,7 @@ class ContentStoreToyCourseTest(ContentStoreTestCase):
|
||||
# Assert is here to make sure that the course being tested actually has verticals (units) to check.
|
||||
self.assertGreater(len(items), 0)
|
||||
for descriptor in items:
|
||||
resp = self.client.get_html(get_url('unit_handler', descriptor.location))
|
||||
resp = self.client.get_html(get_url('container_handler', descriptor.location))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
|
||||
@@ -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="outline outline-course" 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
|
||||
)
|
||||
@@ -1286,14 +1289,9 @@ class ContentStoreTest(ContentStoreTestCase):
|
||||
test_get_html('advanced_settings_handler')
|
||||
test_get_html('textbooks_list_handler')
|
||||
|
||||
# go look at a subsection page
|
||||
subsection_key = course_key.make_usage_key('sequential', 'test_sequence')
|
||||
resp = self.client.get_html(get_url('subsection_handler', subsection_key))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# go look at the Edit page
|
||||
unit_key = course_key.make_usage_key('vertical', 'test_vertical')
|
||||
resp = self.client.get_html(get_url('unit_handler', unit_key))
|
||||
resp = self.client.get_html(get_url('container_handler', unit_key))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
def delete_item(category, name):
|
||||
|
||||
@@ -9,8 +9,9 @@ from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from contentstore import utils
|
||||
from contentstore.tests.utils import CourseTestCase
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey, Location
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
@@ -230,7 +231,7 @@ class XBlockVisibilityTestCase(TestCase):
|
||||
vertical.start = self.future
|
||||
modulestore().update_item(vertical, self.dummy_user)
|
||||
|
||||
self.assertTrue(utils.is_xblock_visible_to_students(vertical))
|
||||
self.assertTrue(utils.is_currently_visible_to_students(vertical))
|
||||
|
||||
def _test_visible_to_students(self, expected_visible_without_lock, name, start_date, publish=False):
|
||||
"""
|
||||
@@ -238,13 +239,13 @@ class XBlockVisibilityTestCase(TestCase):
|
||||
with and without visible_to_staff_only set.
|
||||
"""
|
||||
no_staff_lock = self._create_xblock_with_start_date(name, start_date, publish, visible_to_staff_only=False)
|
||||
self.assertEqual(expected_visible_without_lock, utils.is_xblock_visible_to_students(no_staff_lock))
|
||||
self.assertEqual(expected_visible_without_lock, utils.is_currently_visible_to_students(no_staff_lock))
|
||||
|
||||
# any xblock with visible_to_staff_only set to True should not be visible to students.
|
||||
staff_lock = self._create_xblock_with_start_date(
|
||||
name + "_locked", start_date, publish, visible_to_staff_only=True
|
||||
)
|
||||
self.assertFalse(utils.is_xblock_visible_to_students(staff_lock))
|
||||
self.assertFalse(utils.is_currently_visible_to_students(staff_lock))
|
||||
|
||||
def _create_xblock_with_start_date(self, name, start_date, publish=False, visible_to_staff_only=False):
|
||||
"""Helper to create an xblock with a start date, optionally publishing it"""
|
||||
@@ -260,3 +261,57 @@ class XBlockVisibilityTestCase(TestCase):
|
||||
modulestore().publish(location, self.dummy_user)
|
||||
|
||||
return vertical
|
||||
|
||||
|
||||
class ReleaseDateSourceTest(CourseTestCase):
|
||||
"""Tests for finding the source of an xblock's release date."""
|
||||
|
||||
def setUp(self):
|
||||
super(ReleaseDateSourceTest, self).setUp()
|
||||
|
||||
self.chapter = ItemFactory.create(category='chapter', parent_location=self.course.location)
|
||||
self.sequential = ItemFactory.create(category='sequential', parent_location=self.chapter.location)
|
||||
self.vertical = ItemFactory.create(category='vertical', parent_location=self.sequential.location)
|
||||
|
||||
# Read again so that children lists are accurate
|
||||
self.chapter = self.store.get_item(self.chapter.location)
|
||||
self.sequential = self.store.get_item(self.sequential.location)
|
||||
self.vertical = self.store.get_item(self.vertical.location)
|
||||
|
||||
self.date_one = datetime(1980, 1, 1, tzinfo=UTC)
|
||||
self.date_two = datetime(2020, 1, 1, tzinfo=UTC)
|
||||
|
||||
def _update_release_dates(self, chapter_start, sequential_start, vertical_start):
|
||||
"""Sets the release dates of the chapter, sequential, and vertical"""
|
||||
self.chapter.start = chapter_start
|
||||
self.chapter = self.store.update_item(self.chapter, ModuleStoreEnum.UserID.test)
|
||||
self.sequential.start = sequential_start
|
||||
self.sequential = self.store.update_item(self.sequential, ModuleStoreEnum.UserID.test)
|
||||
self.vertical.start = vertical_start
|
||||
self.vertical = self.store.update_item(self.vertical, ModuleStoreEnum.UserID.test)
|
||||
|
||||
def _verify_release_date_source(self, item, expected_source):
|
||||
"""Helper to verify that the release date source of a given item matches the expected source"""
|
||||
source = utils.find_release_date_source(item)
|
||||
self.assertEqual(source.location, expected_source.location)
|
||||
self.assertEqual(source.start, expected_source.start)
|
||||
|
||||
def test_chapter_source_for_vertical(self):
|
||||
"""Tests a vertical's release date being set by its chapter"""
|
||||
self._update_release_dates(self.date_one, self.date_one, self.date_one)
|
||||
self._verify_release_date_source(self.vertical, self.chapter)
|
||||
|
||||
def test_sequential_source_for_vertical(self):
|
||||
"""Tests a vertical's release date being set by its sequential"""
|
||||
self._update_release_dates(self.date_one, self.date_two, self.date_two)
|
||||
self._verify_release_date_source(self.vertical, self.sequential)
|
||||
|
||||
def test_chapter_source_for_sequential(self):
|
||||
"""Tests a sequential's release date being set by its chapter"""
|
||||
self._update_release_dates(self.date_one, self.date_one, self.date_one)
|
||||
self._verify_release_date_source(self.sequential, self.chapter)
|
||||
|
||||
def test_sequential_source_for_sequential(self):
|
||||
"""Tests a sequential's release date being set by itself"""
|
||||
self._update_release_dates(self.date_one, self.date_two, self.date_two)
|
||||
self._verify_release_date_source(self.sequential, self.sequential)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -308,7 +309,7 @@ class CourseTestCase(ModuleStoreTestCase):
|
||||
# assert is here to make sure that the course being tested actually has verticals (units) to check.
|
||||
self.assertGreater(len(items), 0, "Course has no verticals (units) to check")
|
||||
for descriptor in items:
|
||||
resp = self.client.get_html(get_url('unit_handler', descriptor.location))
|
||||
resp = self.client.get_html(get_url('container_handler', descriptor.location))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
def assertAssetsEqual(self, asset_son, course1_id, course2_id):
|
||||
|
||||
@@ -150,23 +150,11 @@ def course_image_url(course):
|
||||
return path
|
||||
|
||||
|
||||
def compute_publish_state(xblock):
|
||||
# pylint: disable=invalid-name
|
||||
def is_currently_visible_to_students(xblock):
|
||||
"""
|
||||
Returns whether this xblock is draft, public, or private.
|
||||
|
||||
Returns:
|
||||
PublishState.draft - content is in the process of being edited, but still has a previous
|
||||
version deployed to LMS
|
||||
PublishState.public - content is locked and deployed to LMS
|
||||
PublishState.private - content is editable and not deployed to LMS
|
||||
"""
|
||||
|
||||
return modulestore().compute_publish_state(xblock)
|
||||
|
||||
|
||||
def is_xblock_visible_to_students(xblock):
|
||||
"""
|
||||
Returns true if there is a published version of the xblock that has been released.
|
||||
Returns true if there is a published version of the xblock that is currently visible to students.
|
||||
This means that it has a release date in the past, and the xblock has not been set to staff only.
|
||||
"""
|
||||
|
||||
try:
|
||||
@@ -187,6 +175,28 @@ def is_xblock_visible_to_students(xblock):
|
||||
return True
|
||||
|
||||
|
||||
def find_release_date_source(xblock):
|
||||
"""
|
||||
Finds the ancestor of xblock that set its release date.
|
||||
"""
|
||||
|
||||
# Stop searching at the section level
|
||||
if xblock.category == 'chapter':
|
||||
return xblock
|
||||
|
||||
parent_location = modulestore().get_parent_location(xblock.location,
|
||||
revision=ModuleStoreEnum.RevisionOption.draft_preferred)
|
||||
# Orphaned xblocks set their own release date
|
||||
if not parent_location:
|
||||
return xblock
|
||||
|
||||
parent = modulestore().get_item(parent_location)
|
||||
if parent.start != xblock.start:
|
||||
return xblock
|
||||
else:
|
||||
return find_release_date_source(parent)
|
||||
|
||||
|
||||
def add_extra_panel_tab(tab_type, course):
|
||||
"""
|
||||
Used to add the panel tab to a course if it does not exist.
|
||||
|
||||
@@ -11,21 +11,19 @@ from django.conf import settings
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from edxmako.shortcuts import render_to_response
|
||||
|
||||
from util.date_utils import get_default_time_display
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore import PublishState
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
from xblock.core import XBlock
|
||||
from xblock.django.request import webob_to_django_response, django_to_webob_request
|
||||
from xblock.exceptions import NoSuchHandlerError
|
||||
from xblock.fields import Scope
|
||||
from xblock.plugin import PluginMissingError
|
||||
from xblock.runtime import Mixologist
|
||||
|
||||
from contentstore.utils import get_lms_link_for_item, compute_publish_state
|
||||
from contentstore.views.helpers import get_parent_xblock
|
||||
from contentstore.utils import get_lms_link_for_item
|
||||
from contentstore.views.helpers import get_parent_xblock, is_unit, xblock_type_display_name
|
||||
from contentstore.views.item import create_xblock_info
|
||||
|
||||
from models.settings.course_grading import CourseGradingModel
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
|
||||
from .access import has_course_access
|
||||
@@ -33,15 +31,13 @@ from django.utils.translation import ugettext as _
|
||||
|
||||
__all__ = ['OPEN_ENDED_COMPONENT_TYPES',
|
||||
'ADVANCED_COMPONENT_POLICY_KEY',
|
||||
'subsection_handler',
|
||||
'unit_handler',
|
||||
'container_handler',
|
||||
'component_handler'
|
||||
]
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# NOTE: unit_handler assumes this list is disjoint from ADVANCED_COMPONENT_TYPES
|
||||
# NOTE: it is assumed that this list is disjoint from ADVANCED_COMPONENT_TYPES
|
||||
COMPONENT_TYPES = ['discussion', 'html', 'problem', 'video']
|
||||
|
||||
# Constants for determining if these components should be enabled for this course
|
||||
@@ -101,8 +97,8 @@ def subsection_handler(request, usage_key_string):
|
||||
can_view_live = False
|
||||
subsection_units = item.get_children()
|
||||
for unit in subsection_units:
|
||||
state = compute_publish_state(unit)
|
||||
if state in (PublishState.public, PublishState.draft):
|
||||
has_published = modulestore().compute_publish_state(unit) != PublishState.private
|
||||
if has_published:
|
||||
can_view_live = True
|
||||
break
|
||||
|
||||
@@ -135,84 +131,6 @@ def _load_mixed_class(category):
|
||||
return mixologist.mix(component_class)
|
||||
|
||||
|
||||
@require_GET
|
||||
@login_required
|
||||
def unit_handler(request, usage_key_string):
|
||||
"""
|
||||
The restful handler for unit-specific requests.
|
||||
|
||||
GET
|
||||
html: return html page for editing a unit
|
||||
json: not currently supported
|
||||
"""
|
||||
if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'):
|
||||
usage_key = UsageKey.from_string(usage_key_string)
|
||||
try:
|
||||
course, item, lms_link = _get_item_in_course(request, usage_key)
|
||||
except ItemNotFoundError:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
component_templates = get_component_templates(course)
|
||||
|
||||
xblocks = item.get_children()
|
||||
|
||||
# TODO (cpennington): If we share units between courses,
|
||||
# this will need to change to check permissions correctly so as
|
||||
# to pick the correct parent subsection
|
||||
containing_subsection = get_parent_xblock(item)
|
||||
containing_section = get_parent_xblock(containing_subsection)
|
||||
|
||||
# cdodge hack. We're having trouble previewing drafts via jump_to redirect
|
||||
# so let's generate the link url here
|
||||
|
||||
# need to figure out where this item is in the list of children as the
|
||||
# preview will need this
|
||||
index = 1
|
||||
for child in containing_subsection.get_children():
|
||||
if child.location == item.location:
|
||||
break
|
||||
index = index + 1
|
||||
|
||||
preview_lms_base = settings.FEATURES.get('PREVIEW_LMS_BASE')
|
||||
|
||||
preview_lms_link = (
|
||||
u'//{preview_lms_base}/courses/{org}/{course}/{course_name}/courseware/{section}/{subsection}/{index}'
|
||||
).format(
|
||||
preview_lms_base=preview_lms_base,
|
||||
lms_base=settings.LMS_BASE,
|
||||
org=course.location.org,
|
||||
course=course.location.course,
|
||||
course_name=course.location.name,
|
||||
section=containing_section.location.name,
|
||||
subsection=containing_subsection.location.name,
|
||||
index=index
|
||||
)
|
||||
|
||||
return render_to_response('unit.html', {
|
||||
'context_course': course,
|
||||
'unit': item,
|
||||
'unit_usage_key': item.location,
|
||||
'child_usage_keys': [block.scope_ids.usage_id for block in xblocks],
|
||||
'component_templates': json.dumps(component_templates),
|
||||
'draft_preview_link': preview_lms_link,
|
||||
'published_preview_link': lms_link,
|
||||
'subsection': containing_subsection,
|
||||
'release_date': (
|
||||
get_default_time_display(containing_subsection.start)
|
||||
if containing_subsection.start is not None else None
|
||||
),
|
||||
'section': containing_section,
|
||||
'new_unit_category': 'vertical',
|
||||
'unit_state': compute_publish_state(item),
|
||||
'published_date': (
|
||||
get_default_time_display(item.published_date)
|
||||
if item.published_date is not None else None
|
||||
),
|
||||
})
|
||||
else:
|
||||
return HttpResponseBadRequest("Only supports html requests")
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@require_GET
|
||||
@login_required
|
||||
@@ -228,32 +146,75 @@ def container_handler(request, usage_key_string):
|
||||
|
||||
usage_key = UsageKey.from_string(usage_key_string)
|
||||
try:
|
||||
course, xblock, __ = _get_item_in_course(request, usage_key)
|
||||
course, xblock, lms_link = _get_item_in_course(request, usage_key)
|
||||
except ItemNotFoundError:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
component_templates = get_component_templates(course)
|
||||
ancestor_xblocks = []
|
||||
parent = get_parent_xblock(xblock)
|
||||
while parent and parent.category != 'sequential':
|
||||
action = request.REQUEST.get('action', 'view')
|
||||
|
||||
is_unit_page = is_unit(xblock)
|
||||
unit = xblock if is_unit_page else None
|
||||
|
||||
while parent and parent.category != 'course':
|
||||
if unit is None and is_unit(parent):
|
||||
unit = parent
|
||||
ancestor_xblocks.append(parent)
|
||||
parent = get_parent_xblock(parent)
|
||||
ancestor_xblocks.reverse()
|
||||
|
||||
unit = ancestor_xblocks[0] if ancestor_xblocks else None
|
||||
unit_publish_state = compute_publish_state(unit) if unit else None
|
||||
assert unit is not None, "Could not determine unit page"
|
||||
subsection = get_parent_xblock(unit)
|
||||
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)
|
||||
|
||||
# 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')
|
||||
# need to figure out where this item is in the list of children as the
|
||||
# preview will need this
|
||||
index = 1
|
||||
for child in subsection.get_children():
|
||||
if child.location == unit.location:
|
||||
break
|
||||
index += 1
|
||||
preview_lms_link = (
|
||||
u'//{preview_lms_base}/courses/{org}/{course}/{course_name}/courseware/{section}/{subsection}/{index}'
|
||||
).format(
|
||||
preview_lms_base=preview_lms_base,
|
||||
lms_base=settings.LMS_BASE,
|
||||
org=course.location.org,
|
||||
course=course.location.course,
|
||||
course_name=course.location.name,
|
||||
section=section.location.name,
|
||||
subsection=subsection.location.name,
|
||||
index=index
|
||||
)
|
||||
|
||||
return render_to_response('container.html', {
|
||||
'context_course': course, # Needed only for display of menus at top of page.
|
||||
'action': action,
|
||||
'xblock': xblock,
|
||||
'unit_publish_state': unit_publish_state,
|
||||
'xblock_locator': xblock.location,
|
||||
'unit': None if not ancestor_xblocks else ancestor_xblocks[0],
|
||||
'unit': unit,
|
||||
'is_unit_page': is_unit_page,
|
||||
'subsection': subsection,
|
||||
'section': section,
|
||||
'new_unit_category': 'vertical',
|
||||
'ancestor_xblocks': ancestor_xblocks,
|
||||
'component_templates': json.dumps(component_templates),
|
||||
'xblock_info': xblock_info,
|
||||
'draft_preview_link': preview_lms_link,
|
||||
'published_preview_link': lms_link,
|
||||
})
|
||||
else:
|
||||
return HttpResponseBadRequest("Only supports html requests")
|
||||
return HttpResponseBadRequest("Only supports HTML requests")
|
||||
|
||||
|
||||
def get_component_templates(course):
|
||||
@@ -285,16 +246,6 @@ def get_component_templates(course):
|
||||
'video': _("Video")
|
||||
}
|
||||
|
||||
def get_component_display_name(component, default_display_name=None):
|
||||
"""
|
||||
Returns the display name for the specified component.
|
||||
"""
|
||||
component_class = _load_mixed_class(component)
|
||||
if hasattr(component_class, 'display_name') and component_class.display_name.default:
|
||||
return _(component_class.display_name.default)
|
||||
else:
|
||||
return default_display_name
|
||||
|
||||
component_templates = []
|
||||
categories = set()
|
||||
# The component_templates array is in the order of "advanced" (if present), followed
|
||||
@@ -305,7 +256,7 @@ def get_component_templates(course):
|
||||
# add the default template with localized display name
|
||||
# TODO: Once mixins are defined per-application, rather than per-runtime,
|
||||
# this should use a cms mixed-in class. (cpennington)
|
||||
display_name = get_component_display_name(category, _('Blank'))
|
||||
display_name = xblock_type_display_name(category, _('Blank'))
|
||||
templates_for_category.append(create_template_dict(display_name, category))
|
||||
categories.add(category)
|
||||
|
||||
@@ -328,7 +279,7 @@ def get_component_templates(course):
|
||||
for advanced_problem_type in ADVANCED_PROBLEM_TYPES:
|
||||
component = advanced_problem_type['component']
|
||||
boilerplate_name = advanced_problem_type['boilerplate_name']
|
||||
component_display_name = get_component_display_name(component)
|
||||
component_display_name = xblock_type_display_name(component)
|
||||
templates_for_category.append(create_template_dict(component_display_name, component, boilerplate_name))
|
||||
categories.add(component)
|
||||
|
||||
@@ -350,7 +301,7 @@ def get_component_templates(course):
|
||||
if category in ADVANCED_COMPONENT_TYPES and not category in categories:
|
||||
# boilerplates not supported for advanced components
|
||||
try:
|
||||
component_display_name = get_component_display_name(category, default_display_name=category)
|
||||
component_display_name = xblock_type_display_name(category, default_display_name=category)
|
||||
advanced_component_templates['templates'].append(
|
||||
create_template_dict(
|
||||
component_display_name,
|
||||
|
||||
@@ -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,8 @@ 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)))
|
||||
course_module = _get_course_module(CourseKey.from_string(course_key_string), request.user, depth=None)
|
||||
return JsonResponse(_course_outline_json(request, course_module))
|
||||
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 +232,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_module):
|
||||
"""
|
||||
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,
|
||||
course_outline=True,
|
||||
include_children_predicate=lambda xblock: not xblock.category == 'vertical'
|
||||
)
|
||||
|
||||
|
||||
def _accessible_courses_list(request):
|
||||
@@ -381,30 +369,73 @@ def course_index(request, course_key):
|
||||
|
||||
org, course, name: Attributes of the Location for the item to edit
|
||||
"""
|
||||
course_module = _get_course_module(course_key, request.user, depth=3)
|
||||
# A depth of None implies the whole course. The course outline needs this in order to compute has_changes.
|
||||
# A unit may not have a draft version, but one of its components could, and hence the unit itself has changes.
|
||||
course_module = _get_course_module(course_key, request.user, depth=None)
|
||||
lms_link = get_lms_link_for_item(course_module.location)
|
||||
sections = course_module.get_children()
|
||||
course_structure = _course_outline_json(request, course_module)
|
||||
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.get('child_info', None) 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.get('child_info', None) 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):
|
||||
"""
|
||||
@@ -1107,7 +1138,7 @@ class GroupConfiguration(object):
|
||||
continue
|
||||
|
||||
unit_url = reverse_usage_url(
|
||||
'unit_handler',
|
||||
'container_handler',
|
||||
course.location.course_key.make_usage_key(unit.location.block_type, unit.location.name)
|
||||
)
|
||||
usage_info[split_test.user_partition_id].append({
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import logging
|
||||
"""
|
||||
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
|
||||
from django.utils.translation import ugettext as _
|
||||
from edxmako.shortcuts import render_to_string, render_to_response
|
||||
from xblock.core import XBlock
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from contentstore.utils import reverse_course_url, reverse_usage_url
|
||||
|
||||
__all__ = ['edge', 'event', 'landing']
|
||||
|
||||
EDITING_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"
|
||||
]
|
||||
|
||||
# points to the temporary course landing page with log in and sign up
|
||||
def landing(request, org, course, coursename):
|
||||
@@ -51,58 +55,99 @@ def get_parent_xblock(xblock):
|
||||
return modulestore().get_item(parent_location)
|
||||
|
||||
|
||||
def is_unit(xblock):
|
||||
def is_unit(xblock, parent_xblock=None):
|
||||
"""
|
||||
Returns true if the specified xblock is a vertical that is treated as a unit.
|
||||
A unit is a vertical that is a direct child of a sequential (aka a subsection).
|
||||
"""
|
||||
if xblock.category == 'vertical':
|
||||
parent_xblock = get_parent_xblock(xblock)
|
||||
if parent_xblock is None:
|
||||
parent_xblock = get_parent_xblock(xblock)
|
||||
parent_category = parent_xblock.category if parent_xblock else None
|
||||
return parent_category == 'sequential'
|
||||
return False
|
||||
|
||||
|
||||
def xblock_has_own_studio_page(xblock):
|
||||
def xblock_has_own_studio_page(xblock, parent_xblock=None):
|
||||
"""
|
||||
Returns true if the specified xblock has an associated Studio page. Most xblocks do
|
||||
not have their own page but are instead shown on the page of their parent. There
|
||||
are a few exceptions:
|
||||
1. Courses
|
||||
2. Verticals that are either:
|
||||
- themselves treated as units (in which case they are shown on a unit page)
|
||||
- a direct child of a unit (in which case they are shown on a container page)
|
||||
3. XBlocks with children, except for:
|
||||
- sequentials (aka subsections)
|
||||
- chapters (aka sections)
|
||||
- themselves treated as units
|
||||
- a direct child of a unit
|
||||
3. XBlocks that support children
|
||||
"""
|
||||
category = xblock.category
|
||||
|
||||
if is_unit(xblock):
|
||||
if is_unit(xblock, parent_xblock):
|
||||
return True
|
||||
elif category == 'vertical':
|
||||
parent_xblock = get_parent_xblock(xblock)
|
||||
if parent_xblock is None:
|
||||
parent_xblock = get_parent_xblock(xblock)
|
||||
return is_unit(parent_xblock) if parent_xblock else False
|
||||
elif category in ('sequential', 'chapter'):
|
||||
return False
|
||||
|
||||
# All other xblocks with children have their own page
|
||||
return xblock.has_children
|
||||
|
||||
|
||||
def xblock_studio_url(xblock):
|
||||
def xblock_studio_url(xblock, parent_xblock=None):
|
||||
"""
|
||||
Returns the Studio editing URL for the specified xblock.
|
||||
"""
|
||||
if not xblock_has_own_studio_page(xblock):
|
||||
if not xblock_has_own_studio_page(xblock, parent_xblock):
|
||||
return None
|
||||
category = xblock.category
|
||||
parent_xblock = get_parent_xblock(xblock)
|
||||
parent_category = parent_xblock.category if parent_xblock else None
|
||||
if category == 'course':
|
||||
return reverse_course_url('course_handler', xblock.location.course_key)
|
||||
elif category == 'vertical' and parent_category == 'sequential':
|
||||
# only show the unit page for verticals directly beneath a subsection
|
||||
return reverse_usage_url('unit_handler', xblock.location)
|
||||
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)
|
||||
|
||||
|
||||
def xblock_type_display_name(xblock, default_display_name=None):
|
||||
"""
|
||||
Returns the display name for the specified type of xblock. Note that an instance can be passed in
|
||||
for context dependent names, e.g. a vertical beneath a sequential is a Unit.
|
||||
|
||||
:param xblock: An xblock instance or the type of xblock.
|
||||
:param default_display_name: The default value to return if no display name can be found.
|
||||
:return:
|
||||
"""
|
||||
|
||||
if hasattr(xblock, 'category'):
|
||||
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
|
||||
|
||||
@@ -348,7 +348,7 @@ def export_handler(request, course_key_string):
|
||||
'raw_err_msg': str(exc),
|
||||
'failed_module': failed_item,
|
||||
'unit': unit,
|
||||
'edit_unit_url': reverse_usage_url("unit_handler", parent.location) if parent else "",
|
||||
'edit_unit_url': reverse_usage_url("container_handler", parent.location) if parent else "",
|
||||
'course_home_url': reverse_course_url("course_handler", course_key),
|
||||
'export_url': export_url
|
||||
})
|
||||
|
||||
@@ -4,6 +4,9 @@ from __future__ import absolute_import
|
||||
import hashlib
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
from datetime import datetime
|
||||
from pytz import UTC
|
||||
import json
|
||||
|
||||
from collections import OrderedDict
|
||||
from functools import partial
|
||||
@@ -21,30 +24,39 @@ from xblock.fragment import Fragment
|
||||
|
||||
import xmodule
|
||||
from xmodule.tabs import StaticTab, CourseTabList
|
||||
from xmodule.modulestore import PublishState, ModuleStoreEnum
|
||||
from xmodule.modulestore import ModuleStoreEnum, PublishState
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.draft_and_published import DIRECT_ONLY_CATEGORIES
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError, DuplicateItemError
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
from xmodule.x_module import PREVIEW_VIEWS, STUDIO_VIEW
|
||||
from xmodule.x_module import PREVIEW_VIEWS, STUDIO_VIEW, STUDENT_VIEW
|
||||
|
||||
from xmodule.course_module import DEFAULT_START_DATE
|
||||
from contentstore.utils import find_release_date_source
|
||||
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 .helpers import xblock_has_own_studio_page
|
||||
from contentstore.utils import compute_publish_state
|
||||
from contentstore.utils import is_currently_visible_to_students
|
||||
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.
|
||||
@@ -72,7 +84,7 @@ def usage_key_with_run(usage_key_string):
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@require_http_methods(("DELETE", "GET", "PUT", "POST"))
|
||||
@require_http_methods(("DELETE", "GET", "PUT", "POST", "PATCH"))
|
||||
@login_required
|
||||
@expect_json
|
||||
def xblock_handler(request, usage_key_string):
|
||||
@@ -85,7 +97,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.
|
||||
@@ -94,7 +106,12 @@ def xblock_handler(request, usage_key_string):
|
||||
to None! Absent ones will be left alone.
|
||||
:nullout: which metadata fields to set to None
|
||||
:graderType: change how this unit is graded
|
||||
:publish: can be one of three values, 'make_public, 'make_private', or 'create_draft'
|
||||
:publish: can be:
|
||||
'make_public': publish the content
|
||||
'republish': publish this item *only* if it was previously published
|
||||
'discard_changes' - reverts to the last published version
|
||||
Note: If 'discard_changes', the other fields will not be used; that is, it is not possible
|
||||
to update and discard changes in a single operation.
|
||||
The JSON representation on the updated xblock (minus children) is returned.
|
||||
|
||||
if usage_key_string is not specified, create a new xblock instance, either by duplicating
|
||||
@@ -123,7 +140,7 @@ def xblock_handler(request, usage_key_string):
|
||||
# right now can't combine output of this w/ output of _get_module_info, but worthy goal
|
||||
return JsonResponse(CourseGradingModel.get_section_grader_type(usage_key))
|
||||
# TODO: pass fields to _get_module_info and only return those
|
||||
rsp = _get_module_info(usage_key, request.user)
|
||||
rsp = _get_module_info(_get_xblock(usage_key, request.user))
|
||||
return JsonResponse(rsp)
|
||||
else:
|
||||
return HttpResponse(status=406)
|
||||
@@ -132,9 +149,9 @@ def xblock_handler(request, usage_key_string):
|
||||
_delete_item(usage_key, request.user)
|
||||
return JsonResponse()
|
||||
else: # Since we have a usage_key, we are updating an existing xblock.
|
||||
return _save_item(
|
||||
return _save_xblock(
|
||||
request.user,
|
||||
usage_key,
|
||||
_get_xblock(usage_key, request.user),
|
||||
data=request.json.get('data'),
|
||||
children=request.json.get('children'),
|
||||
metadata=request.json.get('metadata'),
|
||||
@@ -185,9 +202,7 @@ def xblock_view_handler(request, usage_key_string, view_name):
|
||||
if 'application/json' in accept_header:
|
||||
store = modulestore()
|
||||
xblock = store.get_item(usage_key)
|
||||
is_read_only = _is_xblock_read_only(xblock)
|
||||
container_views = ['container_preview', 'reorderable_container_child_preview']
|
||||
unit_views = PREVIEW_VIEWS
|
||||
|
||||
# wrap the generated fragment in the xmodule_editor div so that the javascript
|
||||
# can bind to it correctly
|
||||
@@ -204,8 +219,8 @@ def xblock_view_handler(request, usage_key_string, view_name):
|
||||
fragment = Fragment(render_to_string('html_error.html', {'message': str(exc)}))
|
||||
|
||||
store.update_item(xblock, request.user.id)
|
||||
elif view_name in (unit_views + container_views):
|
||||
is_container_view = (view_name in container_views)
|
||||
elif view_name in (PREVIEW_VIEWS + container_views):
|
||||
is_pages_view = view_name == STUDENT_VIEW # Only the "Pages" view uses student view in Studio
|
||||
|
||||
# Determine the items to be shown as reorderable. Note that the view
|
||||
# 'reorderable_container_child_preview' is only rendered for xblocks that
|
||||
@@ -215,27 +230,20 @@ def xblock_view_handler(request, usage_key_string, view_name):
|
||||
if view_name == 'reorderable_container_child_preview':
|
||||
reorderable_items.add(xblock.location)
|
||||
|
||||
# Only show the new style HTML for the container view, i.e. for non-verticals
|
||||
# Note: this special case logic can be removed once the unit page is replaced
|
||||
# with the new container view.
|
||||
# Set up the context to be passed to each XBlock's render method.
|
||||
context = {
|
||||
'container_view': is_container_view,
|
||||
'read_only': is_read_only,
|
||||
'is_pages_view': is_pages_view, # This setting disables the recursive wrapping of xblocks
|
||||
'is_unit_page': is_unit(xblock),
|
||||
'root_xblock': xblock if (view_name == 'container_preview') else None,
|
||||
'reorderable_items': reorderable_items
|
||||
}
|
||||
|
||||
fragment = get_preview_fragment(request, xblock, context)
|
||||
# For old-style pages (such as unit and static pages), wrap the preview with
|
||||
# the component div. Note that the container view recursively adds headers
|
||||
# into the preview fragment, so we don't want to add another header here.
|
||||
if not is_container_view:
|
||||
# For non-leaf xblocks, show the special rendering which links to the new container page.
|
||||
if xblock_has_own_studio_page(xblock):
|
||||
template = 'container_xblock_component.html'
|
||||
else:
|
||||
template = 'component.html'
|
||||
fragment.content = render_to_string(template, {
|
||||
|
||||
# Note that the container view recursively adds headers into the preview fragment,
|
||||
# so only the "Pages" view requires that this extra wrapper be included.
|
||||
if is_pages_view:
|
||||
fragment.content = render_to_string('component.html', {
|
||||
'xblock_context': context,
|
||||
'xblock': xblock,
|
||||
'locator': usage_key,
|
||||
@@ -258,19 +266,36 @@ def xblock_view_handler(request, usage_key_string, view_name):
|
||||
return HttpResponse(status=406)
|
||||
|
||||
|
||||
def _is_xblock_read_only(xblock):
|
||||
# pylint: disable=unused-argument
|
||||
@require_http_methods(("GET"))
|
||||
@login_required
|
||||
@expect_json
|
||||
def xblock_outline_handler(request, usage_key_string):
|
||||
"""
|
||||
Returns true if the specified xblock is read-only, meaning that it cannot be edited.
|
||||
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.
|
||||
"""
|
||||
# We allow direct editing of xblocks in DIRECT_ONLY_CATEGORIES (for example, static pages).
|
||||
if xblock.category in DIRECT_ONLY_CATEGORIES:
|
||||
return False
|
||||
component_publish_state = compute_publish_state(xblock)
|
||||
return component_publish_state == PublishState.public
|
||||
usage_key = usage_key_with_run(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,
|
||||
course_outline=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):
|
||||
def _save_xblock(user, xblock, data=None, children=None, metadata=None, nullout=None,
|
||||
grader_type=None, publish=None):
|
||||
"""
|
||||
Saves xblock w/ its fields. Has special processing for grader_type, publish, and nullout and Nones in metadata.
|
||||
nullout means to truly set the field to None whereas nones in metadata mean to unset them (so they revert
|
||||
@@ -278,38 +303,19 @@ def _save_item(user, usage_key, data=None, children=None, metadata=None, nullout
|
||||
"""
|
||||
store = modulestore()
|
||||
|
||||
try:
|
||||
existing_item = store.get_item(usage_key)
|
||||
except ItemNotFoundError:
|
||||
if usage_key.category in CREATE_IF_NOT_FOUND:
|
||||
# New module at this location, for pages that are not pre-created.
|
||||
# Used for course info handouts.
|
||||
existing_item = store.create_item(user.id, usage_key.course_key, usage_key.block_type, usage_key.block_id)
|
||||
else:
|
||||
raise
|
||||
except InvalidLocationError:
|
||||
log.error("Can't find item by location.")
|
||||
return JsonResponse({"error": "Can't find item by location: " + unicode(usage_key)}, 404)
|
||||
|
||||
old_metadata = own_metadata(existing_item)
|
||||
old_content = existing_item.get_explicitly_set_fields_by_scope(Scope.content)
|
||||
|
||||
if publish:
|
||||
if publish == 'make_private':
|
||||
try:
|
||||
store.unpublish(existing_item.location, user.id),
|
||||
except ItemNotFoundError:
|
||||
pass
|
||||
elif publish == 'create_draft':
|
||||
try:
|
||||
store.convert_to_draft(existing_item.location, user.id)
|
||||
except DuplicateItemError:
|
||||
pass
|
||||
# Don't allow updating an xblock and discarding changes in a single operation (unsupported by UI).
|
||||
if publish == "discard_changes":
|
||||
store.revert_to_published(xblock.location, user.id)
|
||||
# Returning the same sort of result that we do for other save operations. In the future,
|
||||
# we may want to return the full XBlockInfo.
|
||||
return JsonResponse({'id': unicode(xblock.location)})
|
||||
|
||||
old_metadata = own_metadata(xblock)
|
||||
old_content = xblock.get_explicitly_set_fields_by_scope(Scope.content)
|
||||
|
||||
if data:
|
||||
# TODO Allow any scope.content fields not just "data" (exactly like the get below this)
|
||||
existing_item.data = data
|
||||
xblock.data = data
|
||||
else:
|
||||
data = old_content['data'] if 'data' in old_content else None
|
||||
|
||||
@@ -318,7 +324,7 @@ def _save_item(user, usage_key, data=None, children=None, metadata=None, nullout
|
||||
for child in children:
|
||||
child_usage_key = usage_key_with_run(child)
|
||||
children_usage_keys.append(child_usage_key)
|
||||
existing_item.children = children_usage_keys
|
||||
xblock.children = children_usage_keys
|
||||
|
||||
# also commit any metadata which might have been passed along
|
||||
if nullout is not None or metadata is not None:
|
||||
@@ -327,53 +333,61 @@ def _save_item(user, usage_key, data=None, children=None, metadata=None, nullout
|
||||
# 'apply' the submitted metadata, so we don't end up deleting system metadata.
|
||||
if nullout is not None:
|
||||
for metadata_key in nullout:
|
||||
setattr(existing_item, metadata_key, None)
|
||||
setattr(xblock, metadata_key, None)
|
||||
|
||||
# update existing metadata with submitted metadata (which can be partial)
|
||||
# IMPORTANT NOTE: if the client passed 'null' (None) for a piece of metadata that means 'remove it'. If
|
||||
# the intent is to make it None, use the nullout field
|
||||
if metadata is not None:
|
||||
for metadata_key, value in metadata.items():
|
||||
field = existing_item.fields[metadata_key]
|
||||
field = xblock.fields[metadata_key]
|
||||
|
||||
if value is None:
|
||||
field.delete_from(existing_item)
|
||||
field.delete_from(xblock)
|
||||
else:
|
||||
try:
|
||||
value = field.from_json(value)
|
||||
except ValueError:
|
||||
return JsonResponse({"error": "Invalid data"}, 400)
|
||||
field.write_to(existing_item, value)
|
||||
field.write_to(xblock, value)
|
||||
|
||||
if callable(getattr(existing_item, "editor_saved", None)):
|
||||
existing_item.editor_saved(user, old_metadata, old_content)
|
||||
if callable(getattr(xblock, "editor_saved", None)):
|
||||
xblock.editor_saved(user, old_metadata, old_content)
|
||||
|
||||
# commit to datastore
|
||||
store.update_item(existing_item, user.id)
|
||||
store.update_item(xblock, user.id)
|
||||
|
||||
# for static tabs, their containing course also records their display name
|
||||
if usage_key.category == 'static_tab':
|
||||
course = store.get_course(usage_key.course_key)
|
||||
if xblock.location.category == 'static_tab':
|
||||
course = store.get_course(xblock.location.course_key)
|
||||
# find the course's reference to this tab and update the name.
|
||||
static_tab = CourseTabList.get_tab_by_slug(course.tabs, usage_key.name)
|
||||
static_tab = CourseTabList.get_tab_by_slug(course.tabs, xblock.location.name)
|
||||
# only update if changed
|
||||
if static_tab and static_tab['name'] != existing_item.display_name:
|
||||
static_tab['name'] = existing_item.display_name
|
||||
if static_tab and static_tab['name'] != xblock.display_name:
|
||||
static_tab['name'] = xblock.display_name
|
||||
store.update_item(course, user.id)
|
||||
|
||||
result = {
|
||||
'id': unicode(usage_key),
|
||||
'id': unicode(xblock.location),
|
||||
'data': data,
|
||||
'metadata': own_metadata(existing_item)
|
||||
'metadata': own_metadata(xblock)
|
||||
}
|
||||
|
||||
if grader_type is not None:
|
||||
result.update(CourseGradingModel.update_section_grader_type(existing_item, grader_type, user))
|
||||
result.update(CourseGradingModel.update_section_grader_type(xblock, grader_type, user))
|
||||
|
||||
# Make public after updating the xblock, in case the caller asked
|
||||
# for both an update and a publish.
|
||||
if publish and publish == 'make_public':
|
||||
modulestore().publish(existing_item.location, user.id)
|
||||
# If publish is set to 'republish' and this item has previously been published, then this
|
||||
# new item should be republished. This is used by staff locking to ensure that changing the draft
|
||||
# value of the staff lock will also update the published version.
|
||||
if publish == 'republish':
|
||||
published = modulestore().compute_publish_state(xblock) != PublishState.private
|
||||
if published:
|
||||
publish = 'make_public'
|
||||
|
||||
# Make public after updating the xblock, in case the caller asked for both an update and a publish.
|
||||
# Used by Bok Choy tests and by republishing of staff locks.
|
||||
if publish == 'make_public':
|
||||
modulestore().publish(xblock.location, user.id)
|
||||
|
||||
# Note that children aren't being returned until we have a use case.
|
||||
return JsonResponse(result)
|
||||
@@ -538,32 +552,265 @@ def orphan_handler(request, course_key_string):
|
||||
raise PermissionDenied()
|
||||
|
||||
|
||||
def _get_module_info(usage_key, user, rewrite_static_links=True):
|
||||
def _get_xblock(usage_key, user):
|
||||
"""
|
||||
Returns the xblock for the specified usage key. Note: if failing to find a key with a category
|
||||
in the CREATE_IF_NOT_FOUND list, an xblock will be created and saved automatically.
|
||||
"""
|
||||
store = modulestore()
|
||||
try:
|
||||
return store.get_item(usage_key)
|
||||
except ItemNotFoundError:
|
||||
if usage_key.category in CREATE_IF_NOT_FOUND:
|
||||
# Create a new one for certain categories only. Used for course info handouts.
|
||||
return store.create_item(user.id, usage_key.course_key, usage_key.block_type, block_id=usage_key.block_id)
|
||||
else:
|
||||
raise
|
||||
except InvalidLocationError:
|
||||
log.error("Can't find item by location.")
|
||||
return JsonResponse({"error": "Can't find item by location: " + unicode(usage_key)}, 404)
|
||||
|
||||
|
||||
def _get_module_info(xblock, rewrite_static_links=True):
|
||||
"""
|
||||
metadata, data, id representation of a leaf module fetcher.
|
||||
:param usage_key: A UsageKey
|
||||
"""
|
||||
store = modulestore()
|
||||
try:
|
||||
module = store.get_item(usage_key)
|
||||
except ItemNotFoundError:
|
||||
if usage_key.category in CREATE_IF_NOT_FOUND:
|
||||
# Create a new one for certain categories only. Used for course info handouts.
|
||||
module = store.create_item(user.id, usage_key.course_key, usage_key.block_type, block_id=usage_key.block_id)
|
||||
else:
|
||||
raise
|
||||
|
||||
data = getattr(module, 'data', '')
|
||||
data = getattr(xblock, 'data', '')
|
||||
if rewrite_static_links:
|
||||
data = replace_static_urls(
|
||||
data,
|
||||
None,
|
||||
course_id=module.location.course_key
|
||||
course_id=xblock.location.course_key
|
||||
)
|
||||
|
||||
# Note that children aren't being returned until we have a use case.
|
||||
return {
|
||||
'id': unicode(module.location),
|
||||
'data': data,
|
||||
'metadata': own_metadata(module)
|
||||
return create_xblock_info(xblock, data=data, metadata=own_metadata(xblock), include_ancestor_info=True)
|
||||
|
||||
|
||||
def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=False, include_child_info=False,
|
||||
course_outline=False, include_children_predicate=NEVER, parent_xblock=None, graders=None):
|
||||
"""
|
||||
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 three 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
|
||||
course_outline - if true, the xblock is being rendered on behalf of the course outline.
|
||||
There are certain expensive computations that do not need to be included in this case.
|
||||
|
||||
In addition, an optional include_children_predicate argument can be provided to define whether or
|
||||
not a particular xblock should have its children included.
|
||||
"""
|
||||
|
||||
def safe_get_username(user_id):
|
||||
"""
|
||||
Guard against bad user_ids, like the infamous "**replace_user**".
|
||||
Note that this will ignore our special known IDs (ModuleStoreEnum.UserID).
|
||||
We should consider adding special handling for those values.
|
||||
|
||||
:param user_id: the user id to get the username of
|
||||
:return: username, or None if the user does not exist or user_id is None
|
||||
"""
|
||||
if user_id:
|
||||
try:
|
||||
return User.objects.get(id=user_id).username
|
||||
except: # pylint: disable=bare-except
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
is_xblock_unit = is_unit(xblock, parent_xblock)
|
||||
is_unit_with_changes = is_xblock_unit and modulestore().has_changes(xblock)
|
||||
|
||||
if graders is None:
|
||||
graders = CourseGradingModel.fetch(xblock.location.course_key).graders
|
||||
|
||||
# Compute the child info first so it can be included in aggregate information for the parent
|
||||
should_visit_children = include_child_info and (course_outline and not is_xblock_unit or not course_outline)
|
||||
if should_visit_children and xblock.has_children:
|
||||
child_info = _create_xblock_child_info(
|
||||
xblock,
|
||||
course_outline,
|
||||
graders,
|
||||
include_children_predicate=include_children_predicate,
|
||||
)
|
||||
else:
|
||||
child_info = None
|
||||
|
||||
# Treat DEFAULT_START_DATE as a magic number that means the release date has not been set
|
||||
release_date = get_default_time_display(xblock.start) if xblock.start != DEFAULT_START_DATE else None
|
||||
published = modulestore().compute_publish_state(xblock) != PublishState.private
|
||||
|
||||
xblock_info = {
|
||||
"id": unicode(xblock.location),
|
||||
"display_name": xblock.display_name_with_default,
|
||||
"category": xblock.category,
|
||||
"edited_on": get_default_time_display(xblock.subtree_edited_on) if xblock.subtree_edited_on else None,
|
||||
"published": published,
|
||||
"published_on": get_default_time_display(xblock.published_date) if xblock.published_date else None,
|
||||
'studio_url': xblock_studio_url(xblock, parent_xblock),
|
||||
"released_to_students": datetime.now(UTC) > xblock.start,
|
||||
"release_date": release_date,
|
||||
"visibility_state": _compute_visibility_state(xblock, child_info, is_unit_with_changes) if not xblock.category == 'course' else None,
|
||||
"start": xblock.fields['start'].to_json(xblock.start),
|
||||
"graded": xblock.graded,
|
||||
"due_date": get_default_time_display(xblock.due),
|
||||
"due": xblock.fields['due'].to_json(xblock.due),
|
||||
"format": xblock.format,
|
||||
"course_graders": json.dumps([grader.get('type') for grader in graders]),
|
||||
}
|
||||
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, course_outline)
|
||||
if child_info:
|
||||
xblock_info['child_info'] = child_info
|
||||
# Currently, 'edited_by', 'published_by', and 'release_date_from', and 'has_changes' are only used by the
|
||||
# container page when rendering a unit. Since they are expensive to compute, only include them for units
|
||||
# that are not being rendered on the course outline.
|
||||
if is_xblock_unit and not course_outline:
|
||||
xblock_info["edited_by"] = safe_get_username(xblock.subtree_edited_by)
|
||||
xblock_info["published_by"] = safe_get_username(xblock.published_by)
|
||||
xblock_info["currently_visible_to_students"] = is_currently_visible_to_students(xblock)
|
||||
xblock_info['has_changes'] = is_unit_with_changes
|
||||
if release_date:
|
||||
xblock_info["release_date_from"] = _get_release_date_from(xblock)
|
||||
|
||||
return xblock_info
|
||||
|
||||
|
||||
class VisibilityState(object):
|
||||
"""
|
||||
Represents the possible visibility states for an xblock:
|
||||
|
||||
live - the block and all of its descendants are live to students (excluding staff only items)
|
||||
Note: Live means both published and released.
|
||||
|
||||
ready - the block is ready to go live and all of its descendants are live or ready (excluding staff only items)
|
||||
Note: content is ready when it is published and scheduled with a release date in the future.
|
||||
|
||||
unscheduled - the block and all of its descendants have no release date (excluding staff only items)
|
||||
Note: it is valid for items to be published with no release date in which case they are still unscheduled.
|
||||
|
||||
needs_attention - the block or its descendants are not fully live, ready or unscheduled (excluding staff only items)
|
||||
For example: one subsection has draft content, or there's both unreleased and released content in one section.
|
||||
|
||||
staff_only - all of the block's content is to be shown to staff only
|
||||
Note: staff only items do not affect their parent's state.
|
||||
"""
|
||||
live = 'live'
|
||||
ready = 'ready'
|
||||
unscheduled = 'unscheduled'
|
||||
needs_attention = 'needs_attention'
|
||||
staff_only = 'staff_only'
|
||||
|
||||
|
||||
def _compute_visibility_state(xblock, child_info, is_unit_with_changes):
|
||||
"""
|
||||
Returns the current publish state for the specified xblock and its children
|
||||
"""
|
||||
if xblock.visible_to_staff_only:
|
||||
return VisibilityState.staff_only
|
||||
elif is_unit_with_changes:
|
||||
# Note that a unit that has never been published will fall into this category,
|
||||
# as well as previously published units with draft content.
|
||||
return VisibilityState.needs_attention
|
||||
is_unscheduled = xblock.start == DEFAULT_START_DATE
|
||||
is_live = datetime.now(UTC) > xblock.start
|
||||
children = child_info and child_info['children']
|
||||
if children and len(children) > 0:
|
||||
all_staff_only = True
|
||||
all_unscheduled = True
|
||||
all_live = True
|
||||
for child in child_info['children']:
|
||||
child_state = child['visibility_state']
|
||||
if child_state == VisibilityState.needs_attention:
|
||||
return child_state
|
||||
elif not child_state == VisibilityState.staff_only:
|
||||
all_staff_only = False
|
||||
if not child_state == VisibilityState.unscheduled:
|
||||
all_unscheduled = False
|
||||
if not child_state == VisibilityState.live:
|
||||
all_live = False
|
||||
if all_staff_only:
|
||||
return VisibilityState.staff_only
|
||||
elif all_unscheduled:
|
||||
return VisibilityState.unscheduled if is_unscheduled else VisibilityState.needs_attention
|
||||
elif all_live:
|
||||
return VisibilityState.live if is_live else VisibilityState.needs_attention
|
||||
else:
|
||||
return VisibilityState.ready if not is_unscheduled else VisibilityState.needs_attention
|
||||
if is_unscheduled:
|
||||
return VisibilityState.unscheduled
|
||||
elif is_live:
|
||||
return VisibilityState.live
|
||||
else:
|
||||
return VisibilityState.ready
|
||||
|
||||
|
||||
def _create_xblock_ancestor_info(xblock, course_outline):
|
||||
"""
|
||||
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,
|
||||
course_outline=course_outline,
|
||||
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, course_outline, graders, 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, course_outline=course_outline,
|
||||
include_children_predicate=include_children_predicate,
|
||||
parent_xblock=xblock,
|
||||
graders=graders
|
||||
) for child in xblock.get_children()
|
||||
]
|
||||
return child_info
|
||||
|
||||
|
||||
def _get_release_date_from(xblock):
|
||||
"""
|
||||
Returns a string representation of the section or subsection that sets the xblock's release date
|
||||
"""
|
||||
source = find_release_date_source(xblock)
|
||||
# Translators: this will be a part of the release date message.
|
||||
# For example, 'Released: Jul 02, 2014 at 4:00 UTC with Section "Week 1"'
|
||||
return _('{section_or_subsection} "{display_name}"').format(
|
||||
section_or_subsection=xblock_type_display_name(source),
|
||||
display_name=source.display_name_with_default)
|
||||
|
||||
@@ -191,8 +191,8 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
|
||||
"""
|
||||
Wraps the results of rendering an XBlock view in a div which adds a header and Studio action buttons.
|
||||
"""
|
||||
# Only add the Studio wrapper when on the container page. The unit page will remain as is for now.
|
||||
if context.get('container_view', None) and view in PREVIEW_VIEWS:
|
||||
# Only add the Studio wrapper when on the container page. The "Pages" page will remain as is for now.
|
||||
if not context.get('is_pages_view', None) and view in PREVIEW_VIEWS:
|
||||
root_xblock = context.get('root_xblock')
|
||||
is_root = root_xblock and xblock.location == root_xblock.location
|
||||
is_reorderable = _is_xblock_reorderable(xblock, context)
|
||||
|
||||
@@ -3,9 +3,9 @@ Unit tests for the container page.
|
||||
"""
|
||||
|
||||
import re
|
||||
from contentstore.utils import compute_publish_state
|
||||
import datetime
|
||||
from pytz import UTC
|
||||
from contentstore.views.tests.utils import StudioPageTestCase
|
||||
from xmodule.modulestore import PublishState
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.factories import ItemFactory
|
||||
|
||||
@@ -27,6 +27,23 @@ class ContainerPageTestCase(StudioPageTestCase):
|
||||
self.video = self._create_item(self.child_vertical.location, "video", "My Video")
|
||||
self.store = modulestore()
|
||||
|
||||
past = datetime.datetime(1970, 1, 1, tzinfo=UTC)
|
||||
future = datetime.datetime.now(UTC) + datetime.timedelta(days=1)
|
||||
self.released_private_vertical = self._create_item(
|
||||
parent_location=self.sequential.location, category='vertical', display_name='Released Private Unit',
|
||||
start=past)
|
||||
self.unreleased_private_vertical = self._create_item(
|
||||
parent_location=self.sequential.location, category='vertical', display_name='Unreleased Private Unit',
|
||||
start=future)
|
||||
self.released_public_vertical = self._create_item(
|
||||
parent_location=self.sequential.location, category='vertical', display_name='Released Public Unit',
|
||||
start=past)
|
||||
self.unreleased_public_vertical = self._create_item(
|
||||
parent_location=self.sequential.location, category='vertical', display_name='Unreleased Public Unit',
|
||||
start=future)
|
||||
self.store.publish(self.unreleased_public_vertical.location, self.user.id)
|
||||
self.store.publish(self.released_public_vertical.location, self.user.id)
|
||||
|
||||
def test_container_html(self):
|
||||
self._test_html_content(
|
||||
self.child_container,
|
||||
@@ -35,10 +52,16 @@ class ContainerPageTestCase(StudioPageTestCase):
|
||||
'data-locator="{0}" data-course-key="{0.course_key}">'.format(self.child_container.location)
|
||||
),
|
||||
expected_breadcrumbs=(
|
||||
r'<a href="/unit/{}"\s*'
|
||||
r'class="navigation-link navigation-parent">Unit</a>\s*'
|
||||
r'<a href="#" class="navigation-link navigation-current">Split Test</a>'
|
||||
).format(re.escape(unicode(self.vertical.location)))
|
||||
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'),
|
||||
),
|
||||
)
|
||||
|
||||
def test_container_on_container_html(self):
|
||||
@@ -57,15 +80,18 @@ class ContainerPageTestCase(StudioPageTestCase):
|
||||
'data-locator="{0}" data-course-key="{0.course_key}">'.format(draft_container.location)
|
||||
),
|
||||
expected_breadcrumbs=(
|
||||
r'<a href="/unit/{unit}"\s*'
|
||||
r'class="navigation-link navigation-parent">Unit</a>\s*'
|
||||
r'<a href="/container/{split_test}"\s*'
|
||||
r'class="navigation-link navigation-parent">Split Test</a>\s*'
|
||||
r'<a href="#" class="navigation-link navigation-current">Wrapper</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'),
|
||||
),
|
||||
)
|
||||
|
||||
# Test the draft version of the container
|
||||
@@ -82,19 +108,9 @@ class ContainerPageTestCase(StudioPageTestCase):
|
||||
and the breadcrumbs trail is correct.
|
||||
"""
|
||||
html = self.get_page_html(xblock)
|
||||
publish_state = compute_publish_state(xblock)
|
||||
self.assertIn(expected_section_tag, html)
|
||||
# Verify the navigation link at the top of the page is correct.
|
||||
self.assertRegexpMatches(html, expected_breadcrumbs)
|
||||
|
||||
# Verify the link that allows users to change publish status.
|
||||
if publish_state == PublishState.public:
|
||||
expected_message = 'you need to edit unit <a href="/unit/{}">Unit</a> as a draft.'
|
||||
else:
|
||||
expected_message = 'your changes will be published with unit <a href="/unit/{}">Unit</a>.'
|
||||
expected_unit_link = expected_message.format(self.vertical.location)
|
||||
self.assertIn(expected_unit_link, html)
|
||||
|
||||
def test_public_container_preview_html(self):
|
||||
"""
|
||||
Verify that a public xblock's container preview returns the expected HTML.
|
||||
@@ -102,40 +118,17 @@ class ContainerPageTestCase(StudioPageTestCase):
|
||||
published_unit = self.store.publish(self.vertical.location, self.user.id)
|
||||
published_child_container = self.store.get_item(self.child_container.location)
|
||||
published_child_vertical = self.store.get_item(self.child_vertical.location)
|
||||
self.validate_preview_html(published_unit, self.container_view,
|
||||
can_edit=False, can_reorder=False, can_add=False)
|
||||
self.validate_preview_html(published_child_container, self.container_view,
|
||||
can_edit=False, can_reorder=False, can_add=False)
|
||||
self.validate_preview_html(published_child_vertical, self.reorderable_child_view,
|
||||
can_edit=False, can_reorder=False, can_add=False)
|
||||
self.validate_preview_html(published_unit, self.container_view)
|
||||
self.validate_preview_html(published_child_container, self.container_view)
|
||||
self.validate_preview_html(published_child_vertical, self.reorderable_child_view)
|
||||
|
||||
def test_draft_container_preview_html(self):
|
||||
"""
|
||||
Verify that a draft xblock's container preview returns the expected HTML.
|
||||
"""
|
||||
self.validate_preview_html(self.vertical, self.container_view,
|
||||
can_edit=True, can_reorder=True, can_add=True)
|
||||
self.validate_preview_html(self.child_container, self.container_view,
|
||||
can_edit=True, can_reorder=True, can_add=True)
|
||||
self.validate_preview_html(self.child_vertical, self.reorderable_child_view,
|
||||
can_edit=True, can_reorder=True, can_add=True)
|
||||
|
||||
def test_public_child_container_preview_html(self):
|
||||
"""
|
||||
Verify that a public container rendered as a child of the container page returns the expected HTML.
|
||||
"""
|
||||
empty_child_container = self._create_item(self.vertical.location, 'split_test', 'Split Test')
|
||||
published_empty_child_container = self.store.publish(empty_child_container.location, self.user.id)
|
||||
self.validate_preview_html(published_empty_child_container, self.reorderable_child_view,
|
||||
can_reorder=False, can_edit=False, can_add=False)
|
||||
|
||||
def test_draft_child_container_preview_html(self):
|
||||
"""
|
||||
Verify that a draft container rendered as a child of the container page returns the expected HTML.
|
||||
"""
|
||||
empty_child_container = self._create_item(self.vertical.location, 'split_test', 'Split Test')
|
||||
self.validate_preview_html(empty_child_container, self.reorderable_child_view,
|
||||
can_reorder=True, can_edit=True, can_add=False)
|
||||
self.validate_preview_html(self.vertical, self.container_view)
|
||||
self.validate_preview_html(self.child_container, self.container_view)
|
||||
self.validate_preview_html(self.child_vertical, self.reorderable_child_view)
|
||||
|
||||
def _create_item(self, parent_location, category, display_name, **kwargs):
|
||||
"""
|
||||
@@ -146,5 +139,21 @@ class ContainerPageTestCase(StudioPageTestCase):
|
||||
category=category,
|
||||
display_name=display_name,
|
||||
publish_item=False,
|
||||
user_id=self.user.id,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def test_public_child_container_preview_html(self):
|
||||
"""
|
||||
Verify that a public container rendered as a child of the container page returns the expected HTML.
|
||||
"""
|
||||
empty_child_container = self._create_item(self.vertical.location, 'split_test', 'Split Test')
|
||||
published_empty_child_container = self.store.publish(empty_child_container.location, self.user.id)
|
||||
self.validate_preview_html(published_empty_child_container, self.reorderable_child_view, can_add=False)
|
||||
|
||||
def test_draft_child_container_preview_html(self):
|
||||
"""
|
||||
Verify that a draft container rendered as a child of the container page returns the expected HTML.
|
||||
"""
|
||||
empty_child_container = self._create_item(self.vertical.location, 'split_test', 'Split Test')
|
||||
self.validate_preview_html(empty_child_container, self.reorderable_child_view, can_add=False)
|
||||
|
||||
@@ -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 contentstore.views.item import create_xblock_info, VisibilityState
|
||||
from course_action_state.models import CourseRerunState
|
||||
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
|
||||
@@ -102,19 +105,19 @@ class TestCourseIndex(CourseTestCase):
|
||||
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['is_container'])
|
||||
self.assertFalse(json_response['is_draft'])
|
||||
self.assertTrue(json_response['published'])
|
||||
self.assertIsNone(json_response['visibility_state'])
|
||||
|
||||
# Now verify the first child
|
||||
children = json_response['children']
|
||||
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['is_container'])
|
||||
self.assertFalse(first_child_response['is_draft'])
|
||||
self.assertTrue(len(first_child_response['children']) > 0)
|
||||
self.assertTrue(json_response['published'])
|
||||
self.assertEqual(first_child_response['visibility_state'], VisibilityState.unscheduled)
|
||||
self.assertTrue(len(first_child_response['child_info']['children']) > 0)
|
||||
|
||||
# Finally, validate the entire response for consistency
|
||||
self.assert_correct_json_response(json_response)
|
||||
@@ -183,10 +186,90 @@ class TestCourseIndex(CourseTestCase):
|
||||
self.assertIsNotNone(json_response['display_name'])
|
||||
self.assertIsNotNone(json_response['id'])
|
||||
self.assertIsNotNone(json_response['category'])
|
||||
self.assertIsNotNone(json_response['is_draft'])
|
||||
self.assertIsNotNone(json_response['is_container'])
|
||||
if json_response['is_container']:
|
||||
for child_response in json_response['children']:
|
||||
self.assertTrue(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)
|
||||
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'])
|
||||
self.assertIsNone(json_response['visibility_state'])
|
||||
|
||||
# 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(json_response['published'])
|
||||
self.assertEqual(first_child_response['visibility_state'], VisibilityState.unscheduled)
|
||||
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.assertTrue(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)
|
||||
|
||||
@@ -3,7 +3,7 @@ Unit tests for helpers.py.
|
||||
"""
|
||||
|
||||
from contentstore.tests.utils import CourseTestCase
|
||||
from contentstore.views.helpers import xblock_studio_url
|
||||
from contentstore.views.helpers import xblock_studio_url, xblock_type_display_name
|
||||
from xmodule.modulestore.tests.factories import ItemFactory
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ class HelpersTestCase(CourseTestCase):
|
||||
"""
|
||||
Unit tests for helpers.py.
|
||||
"""
|
||||
|
||||
def test_xblock_studio_url(self):
|
||||
|
||||
# Verify course URL
|
||||
@@ -20,18 +21,24 @@ class HelpersTestCase(CourseTestCase):
|
||||
# Verify chapter URL
|
||||
chapter = ItemFactory.create(parent_location=self.course.location, category='chapter',
|
||||
display_name="Week 1")
|
||||
self.assertIsNone(xblock_studio_url(chapter))
|
||||
self.assertEqual(xblock_studio_url(chapter),
|
||||
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 vertical URL
|
||||
# Verify unit URL
|
||||
vertical = ItemFactory.create(parent_location=sequential.location, category='vertical',
|
||||
display_name='Unit')
|
||||
self.assertEqual(xblock_studio_url(vertical),
|
||||
u'/unit/i4x://MITx/999/vertical/Unit')
|
||||
u'/container/i4x://MITx/999/vertical/Unit')
|
||||
|
||||
# Verify child vertical URL
|
||||
child_vertical = ItemFactory.create(parent_location=vertical.location, category='vertical',
|
||||
@@ -43,3 +50,35 @@ class HelpersTestCase(CourseTestCase):
|
||||
video = ItemFactory.create(parent_location=child_vertical.location, category="video",
|
||||
display_name="My Video")
|
||||
self.assertIsNone(xblock_studio_url(video))
|
||||
|
||||
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.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")
|
||||
self.assertEqual(xblock_type_display_name(video), u'Video')
|
||||
self.assertEqual(xblock_type_display_name('video'), u'Video')
|
||||
|
||||
# Verify split test type display names
|
||||
split_test = ItemFactory.create(parent_location=vertical.location, category="split_test")
|
||||
self.assertEqual(xblock_type_display_name(split_test), u'Content Experiment')
|
||||
self.assertEqual(xblock_type_display_name('split_test'), u'Content Experiment')
|
||||
|
||||
@@ -293,7 +293,7 @@ class ExportTestCase(CourseTestCase):
|
||||
"""
|
||||
fake_xblock = ItemFactory.create(parent_location=self.course.location, category='aawefawef')
|
||||
self.store.publish(fake_xblock.location, self.user.id)
|
||||
self._verify_export_failure(u'/unit/i4x://MITx/999/course/Robot_Super_Course')
|
||||
self._verify_export_failure(u'/container/i4x://MITx/999/course/Robot_Super_Course')
|
||||
|
||||
def test_export_failure_subsection_level(self):
|
||||
"""
|
||||
@@ -305,7 +305,7 @@ class ExportTestCase(CourseTestCase):
|
||||
category='aawefawef'
|
||||
)
|
||||
|
||||
self._verify_export_failure(u'/unit/i4x://MITx/999/vertical/foo')
|
||||
self._verify_export_failure(u'/container/i4x://MITx/999/vertical/foo')
|
||||
|
||||
def _verify_export_failure(self, expectedText):
|
||||
""" Export failure helper method. """
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for items views."""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
import ddt
|
||||
|
||||
from mock import patch
|
||||
@@ -19,10 +19,13 @@ from contentstore.views.component import (
|
||||
component_handler, get_component_templates
|
||||
)
|
||||
|
||||
from contentstore.views.item import create_xblock_info, ALWAYS, VisibilityState
|
||||
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
|
||||
@@ -422,18 +425,6 @@ class TestEditItem(ItemTest):
|
||||
|
||||
self.course_update_url = reverse_usage_url("xblock_handler", self.usage_key)
|
||||
|
||||
def verify_publish_state(self, usage_key, expected_publish_state):
|
||||
"""
|
||||
Helper method that gets the item from the module store and verifies that the publish state is as expected.
|
||||
Returns the item corresponding to the given usage_key.
|
||||
"""
|
||||
item = self.get_item_from_modulestore(
|
||||
usage_key,
|
||||
(expected_publish_state == PublishState.private) or (expected_publish_state == PublishState.draft)
|
||||
)
|
||||
self.assertEqual(expected_publish_state, self.store.compute_publish_state(item))
|
||||
return item
|
||||
|
||||
def test_delete_field(self):
|
||||
"""
|
||||
Sending null in for a field 'deletes' it
|
||||
@@ -539,47 +530,107 @@ class TestEditItem(ItemTest):
|
||||
self.assertEqual(unit1_usage_key, children[2])
|
||||
self.assertEqual(unit2_usage_key, children[1])
|
||||
|
||||
def _is_location_published(self, location):
|
||||
"""
|
||||
Returns whether or not the item with given location has a published version.
|
||||
"""
|
||||
return modulestore().has_item(location, revision=ModuleStoreEnum.RevisionOption.published_only)
|
||||
|
||||
def _verify_published_with_no_draft(self, location):
|
||||
"""
|
||||
Verifies the item with given location has a published version and no draft (unpublished changes).
|
||||
"""
|
||||
self.assertTrue(self._is_location_published(location))
|
||||
self.assertFalse(modulestore().has_changes(modulestore().get_item(location)))
|
||||
|
||||
def _verify_published_with_draft(self, location):
|
||||
"""
|
||||
Verifies the item with given location has a published version and also a draft version (unpublished changes).
|
||||
"""
|
||||
self.assertTrue(self._is_location_published(location))
|
||||
self.assertTrue(modulestore().has_changes(modulestore().get_item(location)))
|
||||
|
||||
def test_make_public(self):
|
||||
""" Test making a private problem public (publishing it). """
|
||||
# When the problem is first created, it is only in draft (because of its category).
|
||||
self.verify_publish_state(self.problem_usage_key, PublishState.private)
|
||||
self.assertFalse(self._is_location_published(self.problem_usage_key))
|
||||
self.client.ajax_post(
|
||||
self.problem_update_url,
|
||||
data={'publish': 'make_public'}
|
||||
)
|
||||
self.verify_publish_state(self.problem_usage_key, PublishState.public)
|
||||
|
||||
def test_make_private(self):
|
||||
""" Test making a public problem private (un-publishing it). """
|
||||
# Make problem public.
|
||||
self.client.ajax_post(
|
||||
self.problem_update_url,
|
||||
data={'publish': 'make_public'}
|
||||
)
|
||||
self.verify_publish_state(self.problem_usage_key, PublishState.public)
|
||||
|
||||
# Now make it private
|
||||
self.client.ajax_post(
|
||||
self.problem_update_url,
|
||||
data={'publish': 'make_private'}
|
||||
)
|
||||
self.verify_publish_state(self.problem_usage_key, PublishState.private)
|
||||
self._verify_published_with_no_draft(self.problem_usage_key)
|
||||
|
||||
def test_make_draft(self):
|
||||
""" Test creating a draft version of a public problem. """
|
||||
self._make_draft_content_different_from_published()
|
||||
|
||||
def test_revert_to_published(self):
|
||||
""" Test reverting draft content to published """
|
||||
self._make_draft_content_different_from_published()
|
||||
self.client.ajax_post(
|
||||
self.problem_update_url,
|
||||
data={'publish': 'discard_changes'}
|
||||
)
|
||||
self._verify_published_with_no_draft(self.problem_usage_key)
|
||||
published = modulestore().get_item(self.problem_usage_key, revision=ModuleStoreEnum.RevisionOption.published_only)
|
||||
self.assertIsNone(published.due)
|
||||
|
||||
def test_republish(self):
|
||||
""" Test republishing an item. """
|
||||
new_display_name = 'New Display Name'
|
||||
|
||||
# When the problem is first created, it is only in draft (because of its category).
|
||||
self.assertFalse(self._is_location_published(self.problem_usage_key))
|
||||
|
||||
# Republishing when only in draft will update the draft but not cause a public item to be created.
|
||||
self.client.ajax_post(
|
||||
self.problem_update_url,
|
||||
data={
|
||||
'publish': 'republish',
|
||||
'metadata': {
|
||||
'display_name': new_display_name
|
||||
}
|
||||
}
|
||||
)
|
||||
self.assertFalse(self._is_location_published(self.problem_usage_key))
|
||||
draft = self.get_item_from_modulestore(self.problem_usage_key, verify_is_draft=True)
|
||||
self.assertEqual(draft.display_name, new_display_name)
|
||||
|
||||
# Publish the item
|
||||
self.client.ajax_post(
|
||||
self.problem_update_url,
|
||||
data={'publish': 'make_public'}
|
||||
)
|
||||
|
||||
# Now republishing should update the published version
|
||||
new_display_name_2 = 'New Display Name 2'
|
||||
self.client.ajax_post(
|
||||
self.problem_update_url,
|
||||
data={
|
||||
'publish': 'republish',
|
||||
'metadata': {
|
||||
'display_name': new_display_name_2
|
||||
}
|
||||
}
|
||||
)
|
||||
self._verify_published_with_no_draft(self.problem_usage_key)
|
||||
published = modulestore().get_item(
|
||||
self.problem_usage_key,
|
||||
revision=ModuleStoreEnum.RevisionOption.published_only
|
||||
)
|
||||
self.assertEqual(published.display_name, new_display_name_2)
|
||||
|
||||
def _make_draft_content_different_from_published(self):
|
||||
"""
|
||||
Helper method to create different draft and published versions of a problem.
|
||||
"""
|
||||
# Make problem public.
|
||||
self.client.ajax_post(
|
||||
self.problem_update_url,
|
||||
data={'publish': 'make_public'}
|
||||
)
|
||||
published = self.verify_publish_state(self.problem_usage_key, PublishState.public)
|
||||
|
||||
# Now make it draft, which means both versions will exist.
|
||||
self.client.ajax_post(
|
||||
self.problem_update_url,
|
||||
data={'publish': 'create_draft'}
|
||||
)
|
||||
self.verify_publish_state(self.problem_usage_key, PublishState.draft)
|
||||
self._verify_published_with_no_draft(self.problem_usage_key)
|
||||
published = modulestore().get_item(self.problem_usage_key, revision=ModuleStoreEnum.RevisionOption.published_only)
|
||||
|
||||
# Update the draft version and check that published is different.
|
||||
self.client.ajax_post(
|
||||
@@ -589,6 +640,9 @@ class TestEditItem(ItemTest):
|
||||
updated_draft = self.get_item_from_modulestore(self.problem_usage_key, verify_is_draft=True)
|
||||
self.assertEqual(updated_draft.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC))
|
||||
self.assertIsNone(published.due)
|
||||
# Fetch the published version again to make sure the due date is still unset.
|
||||
published = modulestore().get_item(published.location, revision=ModuleStoreEnum.RevisionOption.published_only)
|
||||
self.assertIsNone(published.due)
|
||||
|
||||
def test_make_public_with_update(self):
|
||||
""" Update a problem and make it public at the same time. """
|
||||
@@ -602,112 +656,6 @@ class TestEditItem(ItemTest):
|
||||
published = self.get_item_from_modulestore(self.problem_usage_key)
|
||||
self.assertEqual(published.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC))
|
||||
|
||||
def test_make_private_with_update(self):
|
||||
""" Make a problem private and update it at the same time. """
|
||||
# Make problem public.
|
||||
self.client.ajax_post(
|
||||
self.problem_update_url,
|
||||
data={'publish': 'make_public'}
|
||||
)
|
||||
self.verify_publish_state(self.problem_usage_key, PublishState.public)
|
||||
|
||||
# Make problem private and update.
|
||||
self.client.ajax_post(
|
||||
self.problem_update_url,
|
||||
data={
|
||||
'metadata': {'due': '2077-10-10T04:00Z'},
|
||||
'publish': 'make_private'
|
||||
}
|
||||
)
|
||||
draft = self.verify_publish_state(self.problem_usage_key, PublishState.private)
|
||||
self.assertEqual(draft.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC))
|
||||
|
||||
def test_create_draft_with_update(self):
|
||||
""" Create a draft and update it at the same time. """
|
||||
# Make problem public.
|
||||
self.client.ajax_post(
|
||||
self.problem_update_url,
|
||||
data={'publish': 'make_public'}
|
||||
)
|
||||
published = self.verify_publish_state(self.problem_usage_key, PublishState.public)
|
||||
|
||||
# Now make it draft, which means both versions will exist.
|
||||
self.client.ajax_post(
|
||||
self.problem_update_url,
|
||||
data={
|
||||
'metadata': {'due': '2077-10-10T04:00Z'},
|
||||
'publish': 'create_draft'
|
||||
}
|
||||
)
|
||||
draft = self.get_item_from_modulestore(self.problem_usage_key, verify_is_draft=True)
|
||||
self.assertEqual(draft.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC))
|
||||
self.assertIsNone(published.due)
|
||||
|
||||
def test_create_draft_with_multiple_requests(self):
|
||||
"""
|
||||
Create a draft request returns already created version if it exists.
|
||||
"""
|
||||
# Make problem public.
|
||||
self.client.ajax_post(
|
||||
self.problem_update_url,
|
||||
data={'publish': 'make_public'}
|
||||
)
|
||||
self.verify_publish_state(self.problem_usage_key, PublishState.public)
|
||||
|
||||
# Now make it draft, which means both versions will exist.
|
||||
self.client.ajax_post(
|
||||
self.problem_update_url,
|
||||
data={
|
||||
'publish': 'create_draft'
|
||||
}
|
||||
)
|
||||
draft_1 = self.verify_publish_state(self.problem_usage_key, PublishState.draft)
|
||||
|
||||
# Now check that when a user sends request to create a draft when there is already a draft version then
|
||||
# user gets that already created draft instead of getting 'DuplicateItemError' exception.
|
||||
self.client.ajax_post(
|
||||
self.problem_update_url,
|
||||
data={
|
||||
'publish': 'create_draft'
|
||||
}
|
||||
)
|
||||
draft_2 = self.verify_publish_state(self.problem_usage_key, PublishState.draft)
|
||||
self.assertIsNotNone(draft_2)
|
||||
self.assertEqual(draft_1, draft_2)
|
||||
|
||||
def test_make_private_with_multiple_requests(self):
|
||||
"""
|
||||
Make private requests gets proper response even if xmodule is already made private.
|
||||
"""
|
||||
# Make problem public.
|
||||
self.client.ajax_post(
|
||||
self.problem_update_url,
|
||||
data={'publish': 'make_public'}
|
||||
)
|
||||
self.assertIsNotNone(self.get_item_from_modulestore(self.problem_usage_key))
|
||||
|
||||
# Now make it private, and check that its version is private
|
||||
resp = self.client.ajax_post(
|
||||
self.problem_update_url,
|
||||
data={
|
||||
'publish': 'make_private'
|
||||
}
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
draft_1 = self.verify_publish_state(self.problem_usage_key, PublishState.private)
|
||||
|
||||
# Now check that when a user sends request to make it private when it already is private then
|
||||
# user gets that private version instead of getting 'ItemNotFoundError' exception.
|
||||
self.client.ajax_post(
|
||||
self.problem_update_url,
|
||||
data={
|
||||
'publish': 'make_private'
|
||||
}
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
draft_2 = self.verify_publish_state(self.problem_usage_key, PublishState.private)
|
||||
self.assertEqual(draft_1, draft_2)
|
||||
|
||||
def test_published_and_draft_contents_with_update(self):
|
||||
""" Create a draft and publish it then modify the draft and check that published content is not modified """
|
||||
|
||||
@@ -716,7 +664,8 @@ class TestEditItem(ItemTest):
|
||||
self.problem_update_url,
|
||||
data={'publish': 'make_public'}
|
||||
)
|
||||
published = self.verify_publish_state(self.problem_usage_key, PublishState.public)
|
||||
self._verify_published_with_no_draft(self.problem_usage_key)
|
||||
published = modulestore().get_item(self.problem_usage_key, revision=ModuleStoreEnum.RevisionOption.published_only)
|
||||
|
||||
# Now make a draft
|
||||
self.client.ajax_post(
|
||||
@@ -724,8 +673,7 @@ class TestEditItem(ItemTest):
|
||||
data={
|
||||
'id': unicode(self.problem_usage_key),
|
||||
'metadata': {},
|
||||
'data': "<p>Problem content draft.</p>",
|
||||
'publish': 'create_draft'
|
||||
'data': "<p>Problem content draft.</p>"
|
||||
}
|
||||
)
|
||||
|
||||
@@ -746,6 +694,9 @@ class TestEditItem(ItemTest):
|
||||
# Both published and draft content should still be different
|
||||
draft = self.get_item_from_modulestore(self.problem_usage_key, verify_is_draft=True)
|
||||
self.assertNotEqual(draft.data, published.data)
|
||||
# Fetch the published version again to make sure the data is correct.
|
||||
published = modulestore().get_item(published.location, revision=ModuleStoreEnum.RevisionOption.published_only)
|
||||
self.assertNotEqual(draft.data, published.data)
|
||||
|
||||
def test_publish_states_of_nested_xblocks(self):
|
||||
""" Test publishing of a unit page containing a nested xblock """
|
||||
@@ -759,8 +710,8 @@ class TestEditItem(ItemTest):
|
||||
|
||||
# The unit and its children should be private initially
|
||||
unit_update_url = reverse_usage_url('xblock_handler', unit_usage_key)
|
||||
self.verify_publish_state(unit_usage_key, PublishState.private)
|
||||
self.verify_publish_state(html_usage_key, PublishState.private)
|
||||
self.assertFalse(self._is_location_published(unit_usage_key))
|
||||
self.assertFalse(self._is_location_published(html_usage_key))
|
||||
|
||||
# Make the unit public and verify that the problem is also made public
|
||||
resp = self.client.ajax_post(
|
||||
@@ -768,8 +719,8 @@ class TestEditItem(ItemTest):
|
||||
data={'publish': 'make_public'}
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.verify_publish_state(unit_usage_key, PublishState.public)
|
||||
self.verify_publish_state(html_usage_key, PublishState.public)
|
||||
self._verify_published_with_no_draft(unit_usage_key)
|
||||
self._verify_published_with_no_draft(html_usage_key)
|
||||
|
||||
# Make a draft for the unit and verify that the problem also has a draft
|
||||
resp = self.client.ajax_post(
|
||||
@@ -777,12 +728,11 @@ class TestEditItem(ItemTest):
|
||||
data={
|
||||
'id': unicode(unit_usage_key),
|
||||
'metadata': {},
|
||||
'publish': 'create_draft'
|
||||
}
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.verify_publish_state(unit_usage_key, PublishState.draft)
|
||||
self.verify_publish_state(html_usage_key, PublishState.draft)
|
||||
self._verify_published_with_draft(unit_usage_key)
|
||||
self._verify_published_with_draft(html_usage_key)
|
||||
|
||||
|
||||
class TestEditSplitModule(ItemTest):
|
||||
@@ -1132,3 +1082,371 @@ 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, course_outline=True)
|
||||
|
||||
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, course_outline=False):
|
||||
"""
|
||||
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, course_outline=course_outline)
|
||||
|
||||
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.assertIsNone(xblock_info.get('edited_by', None))
|
||||
self.assertEqual(xblock_info['course_graders'], '["Homework", "Lab", "Midterm Exam", "Final Exam"]')
|
||||
self.assertEqual(xblock_info['start'], '2030-01-01T00:00:00Z')
|
||||
self.assertEqual(xblock_info['graded'], False)
|
||||
self.assertEqual(xblock_info['due'], None)
|
||||
self.assertEqual(xblock_info['format'], None)
|
||||
|
||||
# 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 sequential.
|
||||
"""
|
||||
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.assertIsNone(xblock_info.get('edited_by', None))
|
||||
|
||||
# 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.assertIsNone(xblock_info.get('edited_by', None))
|
||||
|
||||
# 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,
|
||||
course_outline=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.assertTrue(xblock_info['published'])
|
||||
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
|
||||
course_outline=course_outline
|
||||
)
|
||||
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) is None),
|
||||
course_outline=course_outline
|
||||
)
|
||||
else:
|
||||
self.assertIsNone(xblock_info.get('child_info', None))
|
||||
if xblock_info['category'] == 'vertical' and not course_outline:
|
||||
self.assertEqual(xblock_info['edited_by'], 'testuser')
|
||||
else:
|
||||
self.assertIsNone(xblock_info.get('edited_by', None))
|
||||
|
||||
|
||||
class TestXBlockPublishingInfo(ItemTest):
|
||||
"""
|
||||
Unit tests for XBlock's outline handling.
|
||||
"""
|
||||
FIRST_SUBSECTION_PATH = [0]
|
||||
FIRST_UNIT_PATH = [0, 0]
|
||||
SECOND_UNIT_PATH = [0, 1]
|
||||
|
||||
def _create_child(self, parent, category, display_name, publish_item=False, staff_only=False):
|
||||
"""
|
||||
Creates a child xblock for the given parent.
|
||||
"""
|
||||
return ItemFactory.create(
|
||||
parent_location=parent.location, category=category, display_name=display_name,
|
||||
user_id=self.user.id, publish_item=publish_item, visible_to_staff_only=staff_only
|
||||
)
|
||||
|
||||
def _get_child_xblock_info(self, xblock_info, index):
|
||||
"""
|
||||
Returns the child xblock info at the specified index.
|
||||
"""
|
||||
children = xblock_info['child_info']['children']
|
||||
self.assertTrue(len(children) > index)
|
||||
return children[index]
|
||||
|
||||
def _get_xblock_info(self, location):
|
||||
"""
|
||||
Returns the xblock info for the specified location.
|
||||
"""
|
||||
return create_xblock_info(
|
||||
modulestore().get_item(location),
|
||||
include_child_info=True,
|
||||
include_children_predicate=ALWAYS,
|
||||
)
|
||||
|
||||
def _set_release_date(self, location, start):
|
||||
"""
|
||||
Sets the release date for the specified xblock.
|
||||
"""
|
||||
xblock = modulestore().get_item(location)
|
||||
xblock.start = start
|
||||
self.store.update_item(xblock, self.user.id)
|
||||
|
||||
def _set_staff_only(self, location, staff_only):
|
||||
"""
|
||||
Sets staff only for the specified xblock.
|
||||
"""
|
||||
xblock = modulestore().get_item(location)
|
||||
xblock.visible_to_staff_only = staff_only
|
||||
self.store.update_item(xblock, self.user.id)
|
||||
|
||||
def _set_display_name(self, location, display_name):
|
||||
"""
|
||||
Sets the display name for the specified xblock.
|
||||
"""
|
||||
xblock = modulestore().get_item(location)
|
||||
xblock.display_name = display_name
|
||||
self.store.update_item(xblock, self.user.id)
|
||||
|
||||
def _verify_visibility_state(self, xblock_info, expected_state, path=None):
|
||||
"""
|
||||
Verify the publish state of an item in the xblock_info. If no path is provided
|
||||
then the root item will be verified.
|
||||
"""
|
||||
if path:
|
||||
direct_child_xblock_info = self._get_child_xblock_info(xblock_info, path[0])
|
||||
remaining_path = path[1:] if len(path) > 1 else None
|
||||
self._verify_visibility_state(direct_child_xblock_info, expected_state, remaining_path)
|
||||
else:
|
||||
self.assertEqual(xblock_info['visibility_state'], expected_state)
|
||||
|
||||
def test_empty_chapter(self):
|
||||
empty_chapter = self._create_child(self.course, 'chapter', "Empty Chapter")
|
||||
xblock_info = self._get_xblock_info(empty_chapter.location)
|
||||
self.assertEqual(xblock_info['visibility_state'], VisibilityState.unscheduled)
|
||||
|
||||
def test_empty_sequential(self):
|
||||
chapter = self._create_child(self.course, 'chapter', "Test Chapter")
|
||||
self._create_child(chapter, 'sequential', "Empty Sequential")
|
||||
xblock_info = self._get_xblock_info(chapter.location)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.unscheduled)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.unscheduled, path=self.FIRST_SUBSECTION_PATH)
|
||||
|
||||
def test_published_unit(self):
|
||||
"""
|
||||
Tests the visibility state of a published unit with release date in the future.
|
||||
"""
|
||||
chapter = self._create_child(self.course, 'chapter', "Test Chapter")
|
||||
sequential = self._create_child(chapter, 'sequential', "Test Sequential")
|
||||
self._create_child(sequential, 'vertical', "Published Unit", publish_item=True)
|
||||
self._create_child(sequential, 'vertical', "Staff Only Unit", staff_only=True)
|
||||
self._set_release_date(chapter.location, datetime.now(UTC) + timedelta(days=1))
|
||||
xblock_info = self._get_xblock_info(chapter.location)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.ready)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.ready, path=self.FIRST_SUBSECTION_PATH)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.ready, path=self.FIRST_UNIT_PATH)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=self.SECOND_UNIT_PATH)
|
||||
|
||||
def test_released_unit(self):
|
||||
"""
|
||||
Tests the visibility state of a published unit with release date in the past.
|
||||
"""
|
||||
chapter = self._create_child(self.course, 'chapter', "Test Chapter")
|
||||
sequential = self._create_child(chapter, 'sequential', "Test Sequential")
|
||||
self._create_child(sequential, 'vertical', "Published Unit", publish_item=True)
|
||||
self._create_child(sequential, 'vertical', "Staff Only Unit", staff_only=True)
|
||||
self._set_release_date(chapter.location, datetime.now(UTC) - timedelta(days=1))
|
||||
xblock_info = self._get_xblock_info(chapter.location)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.live)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.live, path=self.FIRST_SUBSECTION_PATH)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.live, path=self.FIRST_UNIT_PATH)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=self.SECOND_UNIT_PATH)
|
||||
|
||||
def test_unpublished_changes(self):
|
||||
"""
|
||||
Tests the visibility state of a published unit with draft (unpublished) changes.
|
||||
"""
|
||||
chapter = self._create_child(self.course, 'chapter', "Test Chapter")
|
||||
sequential = self._create_child(chapter, 'sequential', "Test Sequential")
|
||||
unit = self._create_child(sequential, 'vertical', "Published Unit", publish_item=True)
|
||||
self._create_child(sequential, 'vertical', "Staff Only Unit", staff_only=True)
|
||||
# Setting the display name creates a draft version of unit.
|
||||
self._set_display_name(unit.location, 'Updated Unit')
|
||||
xblock_info = self._get_xblock_info(chapter.location)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.needs_attention)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.needs_attention, path=self.FIRST_SUBSECTION_PATH)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.needs_attention, path=self.FIRST_UNIT_PATH)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=self.SECOND_UNIT_PATH)
|
||||
|
||||
def test_partially_released_section(self):
|
||||
chapter = self._create_child(self.course, 'chapter', "Test Chapter")
|
||||
released_sequential = self._create_child(chapter, 'sequential', "Released Sequential")
|
||||
self._create_child(released_sequential, 'vertical', "Released Unit", publish_item=True)
|
||||
self._create_child(released_sequential, 'vertical', "Staff Only Unit", staff_only=True)
|
||||
self._set_release_date(chapter.location, datetime.now(UTC) - timedelta(days=1))
|
||||
published_sequential = self._create_child(chapter, 'sequential', "Published Sequential")
|
||||
self._create_child(published_sequential, 'vertical', "Published Unit", publish_item=True)
|
||||
self._create_child(published_sequential, 'vertical', "Staff Only Unit", staff_only=True)
|
||||
self._set_release_date(published_sequential.location, datetime.now(UTC) + timedelta(days=1))
|
||||
xblock_info = self._get_xblock_info(chapter.location)
|
||||
|
||||
# Verify the state of the released sequential
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.live, path=[0])
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.live, path=[0, 0])
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=[0, 1])
|
||||
|
||||
# Verify the state of the published sequential
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.ready, path=[1])
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.ready, path=[1, 0])
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=[1, 1])
|
||||
|
||||
# Finally verify the state of the chapter
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.ready)
|
||||
|
||||
def test_staff_only(self):
|
||||
chapter = self._create_child(self.course, 'chapter', "Test Chapter")
|
||||
sequential = self._create_child(chapter, 'sequential', "Test Sequential")
|
||||
unit = self._create_child(sequential, 'vertical', "Published Unit")
|
||||
self._set_staff_only(unit.location, True)
|
||||
xblock_info = self._get_xblock_info(chapter.location)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.staff_only)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=self.FIRST_SUBSECTION_PATH)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=self.FIRST_UNIT_PATH)
|
||||
|
||||
def test_unscheduled_section_with_live_subsection(self):
|
||||
chapter = self._create_child(self.course, 'chapter', "Test Chapter")
|
||||
sequential = self._create_child(chapter, 'sequential', "Test Sequential")
|
||||
self._create_child(sequential, 'vertical', "Published Unit", publish_item=True)
|
||||
self._create_child(sequential, 'vertical', "Staff Only Unit", staff_only=True)
|
||||
self._set_release_date(sequential.location, datetime.now(UTC) - timedelta(days=1))
|
||||
xblock_info = self._get_xblock_info(chapter.location)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.needs_attention)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.live, path=self.FIRST_SUBSECTION_PATH)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.live, path=self.FIRST_UNIT_PATH)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=self.SECOND_UNIT_PATH)
|
||||
|
||||
def test_unreleased_section_with_live_subsection(self):
|
||||
chapter = self._create_child(self.course, 'chapter', "Test Chapter")
|
||||
sequential = self._create_child(chapter, 'sequential', "Test Sequential")
|
||||
self._create_child(sequential, 'vertical', "Published Unit", publish_item=True)
|
||||
self._create_child(sequential, 'vertical', "Staff Only Unit", staff_only=True)
|
||||
self._set_release_date(chapter.location, datetime.now(UTC) + timedelta(days=1))
|
||||
self._set_release_date(sequential.location, datetime.now(UTC) - timedelta(days=1))
|
||||
xblock_info = self._get_xblock_info(chapter.location)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.needs_attention)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.live, path=self.FIRST_SUBSECTION_PATH)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.live, path=self.FIRST_UNIT_PATH)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=self.SECOND_UNIT_PATH)
|
||||
|
||||
@@ -38,7 +38,11 @@ class GetPreviewHtmlTestCase(TestCase):
|
||||
request.session = {}
|
||||
|
||||
# Call get_preview_fragment directly.
|
||||
html = get_preview_fragment(request, html, {}).content
|
||||
context = {
|
||||
'reorderable_items': set(),
|
||||
'read_only': True
|
||||
}
|
||||
html = get_preview_fragment(request, html, context).content
|
||||
|
||||
# Verify student view html is returned, and the usage ID is as expected.
|
||||
self.assertRegexpMatches(
|
||||
|
||||
@@ -21,35 +21,18 @@ class UnitPageTestCase(StudioPageTestCase):
|
||||
category="video", display_name="My Video")
|
||||
self.store = modulestore()
|
||||
|
||||
def test_public_unit_page_html(self):
|
||||
"""
|
||||
Verify that an xblock returns the expected HTML for a public unit page.
|
||||
"""
|
||||
|
||||
html = self.get_page_html(self.vertical)
|
||||
self.validate_html_for_add_buttons(html)
|
||||
|
||||
def test_draft_unit_page_html(self):
|
||||
"""
|
||||
Verify that an xblock returns the expected HTML for a draft unit page.
|
||||
"""
|
||||
html = self.get_page_html(self.vertical)
|
||||
self.validate_html_for_add_buttons(html)
|
||||
|
||||
def test_public_component_preview_html(self):
|
||||
"""
|
||||
Verify that a public xblock's preview returns the expected HTML.
|
||||
"""
|
||||
published_video = self.store.publish(self.video.location, self.user.id)
|
||||
self.validate_preview_html(self.video, STUDENT_VIEW,
|
||||
can_edit=True, can_reorder=True, can_add=False)
|
||||
self.validate_preview_html(self.video, STUDENT_VIEW, can_add=False)
|
||||
|
||||
def test_draft_component_preview_html(self):
|
||||
"""
|
||||
Verify that a draft xblock's preview returns the expected HTML.
|
||||
"""
|
||||
self.validate_preview_html(self.video, STUDENT_VIEW,
|
||||
can_edit=True, can_reorder=True, can_add=False)
|
||||
self.validate_preview_html(self.video, STUDENT_VIEW, can_add=False)
|
||||
|
||||
def test_public_child_container_preview_html(self):
|
||||
"""
|
||||
@@ -61,8 +44,7 @@ class UnitPageTestCase(StudioPageTestCase):
|
||||
ItemFactory.create(parent_location=child_container.location,
|
||||
category='html', display_name='grandchild')
|
||||
published_child_container = self.store.publish(child_container.location, self.user.id)
|
||||
self.validate_preview_html(published_child_container, STUDENT_VIEW,
|
||||
can_reorder=True, can_edit=True, can_add=False)
|
||||
self.validate_preview_html(published_child_container, STUDENT_VIEW, can_add=False)
|
||||
|
||||
def test_draft_child_container_preview_html(self):
|
||||
"""
|
||||
@@ -74,5 +56,4 @@ class UnitPageTestCase(StudioPageTestCase):
|
||||
ItemFactory.create(parent_location=child_container.location,
|
||||
category='html', display_name='grandchild')
|
||||
draft_child_container = self.store.get_item(child_container.location)
|
||||
self.validate_preview_html(draft_child_container, STUDENT_VIEW,
|
||||
can_reorder=True, can_edit=True, can_add=False)
|
||||
self.validate_preview_html(draft_child_container, STUDENT_VIEW, can_add=False)
|
||||
|
||||
@@ -41,19 +41,16 @@ class StudioPageTestCase(CourseTestCase):
|
||||
resp_content = json.loads(resp.content)
|
||||
return resp_content['html']
|
||||
|
||||
def validate_preview_html(self, xblock, view_name, can_edit=True, can_reorder=True, can_add=True):
|
||||
def validate_preview_html(self, xblock, view_name, can_add=True):
|
||||
"""
|
||||
Verify that the specified xblock's preview has the expected HTML elements.
|
||||
"""
|
||||
html = self.get_preview_html(xblock, view_name)
|
||||
self.validate_html_for_add_buttons(html, can_add=can_add)
|
||||
self.validate_html_for_add_buttons(html, can_add)
|
||||
|
||||
# Verify that there are no drag handles for public blocks
|
||||
# Verify drag handles always appear.
|
||||
drag_handle_html = '<span data-tooltip="Drag to reorder" class="drag-handle action"></span>'
|
||||
if can_reorder:
|
||||
self.assertIn(drag_handle_html, html)
|
||||
else:
|
||||
self.assertNotIn(drag_handle_html, html)
|
||||
self.assertIn(drag_handle_html, html)
|
||||
|
||||
# Verify that there are no action buttons for public blocks
|
||||
expected_button_html = [
|
||||
@@ -62,10 +59,7 @@ class StudioPageTestCase(CourseTestCase):
|
||||
'<a href="#" data-tooltip="Duplicate" class="duplicate-button action-button">'
|
||||
]
|
||||
for button_html in expected_button_html:
|
||||
if can_edit:
|
||||
self.assertIn(button_html, html)
|
||||
else:
|
||||
self.assertNotIn(button_html, html)
|
||||
self.assertIn(button_html, html)
|
||||
|
||||
def validate_html_for_add_buttons(self, html, can_add=True):
|
||||
"""
|
||||
|
||||
@@ -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,27 @@ define([
|
||||
|
||||
"js/spec/models/component_template_spec",
|
||||
"js/spec/models/explicit_url_spec",
|
||||
"js/spec/models/xblock_info_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/unit_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/xblock_string_field_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,99 +0,0 @@
|
||||
define ["js/views/overview", "js/views/feedback_notification", "js/spec_helpers/create_sinon", "js/base", "date", "jquery.timepicker"],
|
||||
(Overview, Notification, create_sinon) ->
|
||||
|
||||
describe "Course Overview", ->
|
||||
beforeEach ->
|
||||
appendSetFixtures """
|
||||
<div class="section-published-date">
|
||||
<span class="published-status">
|
||||
<strong>Release date:</strong> 06/12/2013 at 04:00 UTC
|
||||
</span>
|
||||
<a href="#" class="edit-release-date action " data-date="06/12/2013" data-time="04:00" data-locator="i4x://pfogg/42/chapter/d6b47f7b084f49debcaf67fe5436c8e2"><i class="icon-time"></i> <span class="sr">Edit section release date</span></a>
|
||||
</div>
|
||||
"""
|
||||
|
||||
appendSetFixtures """
|
||||
<div class="wrapper wrapper-dialog 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="dialog confirm">
|
||||
<form class="edit-sectionrelease-dialog" action="#">
|
||||
<div class="form-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 - <strong class="section-name"></strong> - will be released to students. Any units marked private will only be visible to admins.</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="04/08/1990" placeholder="MM/DD/YYYY" class="date" size='15' autocomplete="off"/>
|
||||
</li>
|
||||
<li class="field field-start-time">
|
||||
<label for="start_time">Release Time (<abbr title="Coordinated Universal Time">UTC</abbr>)</label>
|
||||
<input class="start-time time" type="text" name="start_time" value="12:00" placeholder="HH:MM" class="time" size='10' autocomplete="off"/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="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>
|
||||
"""
|
||||
|
||||
appendSetFixtures """
|
||||
<section class="courseware-section is-collapsible is-draggable" data-parent="a-parent-locator-goes-here" data-locator="a-location-goes-here">
|
||||
<li class="branch collapsed id-holder" data-locator="an-id-goes-here">
|
||||
<a href="#" data-tooltip="Delete this section" class="delete-section-button"><i class="icon-trash"></i> <span class="sr">Delete section</span></a>
|
||||
</li>
|
||||
</section>
|
||||
"""
|
||||
|
||||
spyOn(Overview, 'saveSetSectionScheduleDate').andCallThrough()
|
||||
# Have to do this here, as it normally gets bound in document.ready()
|
||||
$('a.action-save').click(Overview.saveSetSectionScheduleDate)
|
||||
$('a.delete-section-button').click(deleteSection)
|
||||
$(".edit-subsection-publish-settings .start-date").datepicker()
|
||||
|
||||
@notificationSpy = spyOn(Notification.Mini.prototype, 'show').andCallThrough()
|
||||
window.analytics = jasmine.createSpyObj('analytics', ['track'])
|
||||
window.course_location_analytics = jasmine.createSpy()
|
||||
|
||||
afterEach ->
|
||||
delete window.analytics
|
||||
delete window.course_location_analytics
|
||||
@notificationSpy.reset()
|
||||
|
||||
it "should save model when save is clicked", ->
|
||||
$('a.edit-release-date').click()
|
||||
$('a.action-save').click()
|
||||
expect(Overview.saveSetSectionScheduleDate).toHaveBeenCalled()
|
||||
|
||||
it "should show a confirmation on save", ->
|
||||
$('a.edit-release-date').click()
|
||||
$('a.action-save').click()
|
||||
expect(@notificationSpy).toHaveBeenCalled()
|
||||
|
||||
# Fails sporadically in Jenkins.
|
||||
# it "should delete model when delete is clicked", ->
|
||||
# $('a.delete-section-button').click()
|
||||
# $('a.action-primary').click()
|
||||
# expect(@requests[0].url).toEqual('/delete_item')
|
||||
|
||||
it "should not delete model when cancel is clicked", ->
|
||||
requests = create_sinon["requests"](this)
|
||||
|
||||
$('a.delete-section-button').click()
|
||||
$('a.action-secondary').click()
|
||||
expect(requests.length).toEqual(0)
|
||||
|
||||
# Fails sporadically in Jenkins.
|
||||
# it "should show a confirmation on delete", ->
|
||||
# $('a.delete-section-button').click()
|
||||
# $('a.action-primary').click()
|
||||
# expect(@notificationSpy).toHaveBeenCalled()
|
||||
@@ -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,273 +0,0 @@
|
||||
define ["jquery", "jquery.ui", "gettext", "backbone",
|
||||
"js/views/feedback_notification", "js/views/feedback_prompt",
|
||||
"coffee/src/views/module_edit", "js/models/module_info",
|
||||
"js/views/baseview", "js/views/components/add_xblock"],
|
||||
($, ui, gettext, Backbone, NotificationView, PromptView, ModuleEditView, ModuleModel, BaseView, AddXBlockComponent) ->
|
||||
class UnitEditView extends BaseView
|
||||
events:
|
||||
'click .delete-draft': 'deleteDraft'
|
||||
'click .create-draft': 'createDraft'
|
||||
'click .publish-draft': 'publishDraft'
|
||||
'change .visibility-select': 'setVisibility'
|
||||
"click .component-actions .duplicate-button": 'duplicateComponent'
|
||||
|
||||
initialize: =>
|
||||
@visibilityView = new UnitEditView.Visibility(
|
||||
el: @$('.visibility-select')
|
||||
model: @model
|
||||
)
|
||||
|
||||
@locationView = new UnitEditView.LocationState(
|
||||
el: @$('.section-item.editing a')
|
||||
model: @model
|
||||
)
|
||||
|
||||
@nameView = new UnitEditView.NameEdit(
|
||||
el: @$('.unit-name-input')
|
||||
model: @model
|
||||
)
|
||||
|
||||
@addXBlockComponent = new AddXBlockComponent(
|
||||
collection: @options.templates
|
||||
el: @$('.add-xblock-component')
|
||||
createComponent: (template) =>
|
||||
return @createComponent(template, "Creating new component").done(
|
||||
(editor) ->
|
||||
listPanel = @$newComponentItem.prev()
|
||||
listPanel.append(editor.$el)
|
||||
))
|
||||
@addXBlockComponent.render()
|
||||
|
||||
@model.on('change:state', @render)
|
||||
|
||||
@$newComponentItem = @$('.new-component-item')
|
||||
|
||||
@$('.components').sortable(
|
||||
handle: '.drag-handle'
|
||||
update: (event, ui) =>
|
||||
analytics.track "Reordered Components",
|
||||
course: course_location_analytics
|
||||
id: unit_location_analytics
|
||||
|
||||
payload = children : @components()
|
||||
saving = new NotificationView.Mini
|
||||
title: gettext('Saving…')
|
||||
saving.show()
|
||||
options = success : =>
|
||||
@model.unset('children')
|
||||
saving.hide()
|
||||
@model.save(payload, options)
|
||||
helper: 'clone'
|
||||
opacity: '0.5'
|
||||
placeholder: 'component-placeholder'
|
||||
forcePlaceholderSize: true
|
||||
axis: 'y'
|
||||
items: '> .component'
|
||||
)
|
||||
|
||||
@$('.component').each (idx, element) =>
|
||||
model = new ModuleModel
|
||||
id: $(element).data('locator')
|
||||
new ModuleEditView
|
||||
el: element,
|
||||
onDelete: @deleteComponent,
|
||||
model: model
|
||||
|
||||
createComponent: (data, analytics_message) =>
|
||||
self = this
|
||||
operation = $.Deferred()
|
||||
editor = new ModuleEditView(
|
||||
onDelete: @deleteComponent
|
||||
model: new ModuleModel()
|
||||
)
|
||||
|
||||
callback = ->
|
||||
operation.resolveWith(self, [editor])
|
||||
analytics.track analytics_message,
|
||||
course: course_location_analytics
|
||||
unit_id: unit_location_analytics
|
||||
type: editor.$el.data('locator')
|
||||
|
||||
editor.createItem(
|
||||
@$el.data('locator'),
|
||||
data,
|
||||
callback
|
||||
)
|
||||
|
||||
return operation.promise()
|
||||
|
||||
duplicateComponent: (event) =>
|
||||
self = this
|
||||
event.preventDefault()
|
||||
$component = $(event.currentTarget).parents('.component')
|
||||
source_locator = $component.data('locator')
|
||||
@runOperationShowingMessage(gettext('Duplicating…'), ->
|
||||
operation = self.createComponent(
|
||||
{duplicate_source_locator: source_locator},
|
||||
"Duplicating " + source_locator);
|
||||
operation.done(
|
||||
(editor) ->
|
||||
originalOffset = @getScrollOffset($component)
|
||||
$component.after(editor.$el)
|
||||
# Scroll the window so that the new component replaces the old one
|
||||
@setScrollOffset(editor.$el, originalOffset)
|
||||
))
|
||||
|
||||
components: => @$('.component').map((idx, el) -> $(el).data('locator')).get()
|
||||
|
||||
wait: (value) =>
|
||||
@$('.unit-body').toggleClass("waiting", value)
|
||||
|
||||
render: =>
|
||||
if @model.hasChanged('state')
|
||||
@$el.toggleClass("edit-state-#{@model.previous('state')} edit-state-#{@model.get('state')}")
|
||||
@wait(false)
|
||||
|
||||
saveDraft: =>
|
||||
@model.save()
|
||||
|
||||
deleteComponent: (event) =>
|
||||
self = this
|
||||
event.preventDefault()
|
||||
@confirmThenRunOperation(gettext('Delete this component?'),
|
||||
gettext('Deleting this component is permanent and cannot be undone.'),
|
||||
gettext('Yes, delete this component'),
|
||||
->
|
||||
self.runOperationShowingMessage(gettext('Deleting…'),
|
||||
->
|
||||
$component = $(event.currentTarget).parents('.component')
|
||||
return $.ajax({
|
||||
type: 'DELETE',
|
||||
url: self.model.urlRoot + "/" + $component.data('locator')
|
||||
}).success(=>
|
||||
analytics.track "Deleted a Component",
|
||||
course: course_location_analytics
|
||||
unit_id: unit_location_analytics
|
||||
id: $component.data('locator')
|
||||
|
||||
$component.remove()
|
||||
# b/c we don't vigilantly keep children up to date
|
||||
# get rid of it before it hurts someone
|
||||
self.model.save({children: self.components()},
|
||||
{
|
||||
success: (model) ->
|
||||
model.unset('children')
|
||||
})
|
||||
)))
|
||||
|
||||
deleteDraft: (event) ->
|
||||
@wait(true)
|
||||
$.ajax({
|
||||
type: 'DELETE',
|
||||
url: @model.url()
|
||||
}).success(=>
|
||||
|
||||
analytics.track "Deleted Draft",
|
||||
course: course_location_analytics
|
||||
unit_id: unit_location_analytics
|
||||
|
||||
window.location.reload()
|
||||
)
|
||||
|
||||
createDraft: (event) ->
|
||||
self = this
|
||||
@disableElementWhileRunning($(event.target), ->
|
||||
self.wait(true)
|
||||
$.postJSON(self.model.url(), {
|
||||
publish: 'create_draft'
|
||||
}, =>
|
||||
analytics.track "Created Draft",
|
||||
course: course_location_analytics
|
||||
unit_id: unit_location_analytics
|
||||
|
||||
self.model.set('state', 'draft')
|
||||
)
|
||||
)
|
||||
|
||||
publishDraft: (event) ->
|
||||
self = this
|
||||
@disableElementWhileRunning($(event.target), ->
|
||||
self.wait(true)
|
||||
self.saveDraft()
|
||||
|
||||
$.postJSON(self.model.url(), {
|
||||
publish: 'make_public'
|
||||
}, =>
|
||||
analytics.track "Published Draft",
|
||||
course: course_location_analytics
|
||||
unit_id: unit_location_analytics
|
||||
|
||||
self.model.set('state', 'public')
|
||||
)
|
||||
)
|
||||
|
||||
setVisibility: (event) ->
|
||||
if @$('.visibility-select').val() == 'private'
|
||||
action = 'make_private'
|
||||
visibility = "private"
|
||||
else
|
||||
action = 'make_public'
|
||||
visibility = "public"
|
||||
|
||||
@wait(true)
|
||||
|
||||
$.postJSON(@model.url(), {
|
||||
publish: action
|
||||
}, =>
|
||||
analytics.track "Set Unit Visibility",
|
||||
course: course_location_analytics
|
||||
unit_id: unit_location_analytics
|
||||
visibility: visibility
|
||||
|
||||
@model.set('state', @$('.visibility-select').val()))
|
||||
|
||||
class UnitEditView.NameEdit extends BaseView
|
||||
events:
|
||||
'change .unit-display-name-input': 'saveName'
|
||||
|
||||
initialize: =>
|
||||
@model.on('change:metadata', @render)
|
||||
@model.on('change:state', @setEnabled)
|
||||
@setEnabled()
|
||||
@saveName
|
||||
@$spinner = $('<span class="spinner-in-field-icon"></span>');
|
||||
|
||||
render: =>
|
||||
@$('.unit-display-name-input').val(@model.get('metadata').display_name)
|
||||
|
||||
setEnabled: =>
|
||||
disabled = @model.get('state') == 'public'
|
||||
if disabled
|
||||
@$('.unit-display-name-input').attr('disabled', true)
|
||||
else
|
||||
@$('.unit-display-name-input').removeAttr('disabled')
|
||||
|
||||
saveName: =>
|
||||
# Treat the metadata dictionary as immutable
|
||||
metadata = $.extend({}, @model.get('metadata'))
|
||||
metadata.display_name = @$('.unit-display-name-input').val()
|
||||
@model.save(metadata: metadata)
|
||||
# Update name shown in the right-hand side location summary.
|
||||
$('.unit-location .editing .unit-name').html(metadata.display_name)
|
||||
analytics.track "Edited Unit Name",
|
||||
course: course_location_analytics
|
||||
unit_id: unit_location_analytics
|
||||
display_name: metadata.display_name
|
||||
|
||||
|
||||
class UnitEditView.LocationState extends BaseView
|
||||
initialize: =>
|
||||
@model.on('change:state', @render)
|
||||
|
||||
render: =>
|
||||
@$el.toggleClass("#{@model.previous('state')}-item #{@model.get('state')}-item")
|
||||
|
||||
class UnitEditView.Visibility extends BaseView
|
||||
initialize: =>
|
||||
@model.on('change:state', @render)
|
||||
@render()
|
||||
|
||||
render: =>
|
||||
@$el.val(@model.get('state'))
|
||||
|
||||
return UnitEditView
|
||||
|
||||
@@ -1,34 +1,19 @@
|
||||
require(["domReady", "jquery", "underscore", "gettext", "js/views/feedback_notification", "js/views/feedback_prompt",
|
||||
"js/utils/get_date", "js/utils/module", "js/utils/handle_iframe_binding", "js/utils/change_on_enter", "jquery.ui",
|
||||
"jquery.leanModal", "jquery.form", "jquery.smoothScroll"],
|
||||
function(domReady, $, _, gettext, NotificationView, PromptView, DateUtils, ModuleUtils, IframeUtils, TriggerChangeEventOnEnter)
|
||||
"js/utils/date_utils", "js/utils/module", "js/utils/handle_iframe_binding",
|
||||
"jquery.ui", "jquery.leanModal", "jquery.form", "jquery.smoothScroll"],
|
||||
function(domReady, $, _, gettext, NotificationView, PromptView, DateUtils, ModuleUtils, IframeUtils)
|
||||
{
|
||||
|
||||
var $body;
|
||||
var $newComponentItem;
|
||||
var $changedInput;
|
||||
var $spinner;
|
||||
var $newComponentTypePicker;
|
||||
var $newComponentTemplatePickers;
|
||||
var $newComponentButton;
|
||||
|
||||
domReady(function() {
|
||||
$body = $('body');
|
||||
|
||||
$newComponentItem = $('.new-component-item');
|
||||
$newComponentTypePicker = $('.new-component');
|
||||
$newComponentTemplatePickers = $('.new-component-templates');
|
||||
$newComponentButton = $('.new-component-button');
|
||||
$spinner = $('<span class="spinner-in-field-icon"></span>');
|
||||
|
||||
$body.on('click', '.embeddable-xml-input', function() {
|
||||
$(this).select();
|
||||
});
|
||||
|
||||
$('body').addClass('js');
|
||||
|
||||
$('.unit .item-actions .delete-unit-button').bind('click', deleteUnit);
|
||||
$('.new-unit-item').bind('click', createNewUnit);
|
||||
$body.addClass('js');
|
||||
|
||||
// lean/simple modal
|
||||
$('a[rel*=modal]').leanModal({
|
||||
@@ -69,7 +54,8 @@ domReady(function() {
|
||||
});
|
||||
|
||||
// general link management - new window/tab
|
||||
$('a[rel="external"]').attr('title', gettext('This link will open in a new browser window/tab')).bind('click', linkNewWindow);
|
||||
$('a[rel="external"]:not([title])').attr('title', gettext('This link will open in a new browser window/tab'));
|
||||
$('a[rel="external"]').attr('target', '_blank');
|
||||
|
||||
// general link management - lean modal window
|
||||
$('a[rel="modal"]').attr('title', gettext('This link will open in a modal window')).leanModal({
|
||||
@@ -86,37 +72,12 @@ domReady(function() {
|
||||
// tender feedback window scrolling
|
||||
$('a.show-tender').bind('click', smoothScrollTop);
|
||||
|
||||
// autosave when leaving input field
|
||||
$body.on('change', '.subsection-display-name-input', saveSubsection);
|
||||
$('.subsection-display-name-input').each(function() {
|
||||
this.val = $(this).val();
|
||||
});
|
||||
$("#start_date, #start_time, #due_date, #due_time").change(autosaveInput).keyup(TriggerChangeEventOnEnter)
|
||||
$('.sync-date, .remove-date').bind('click', autosaveInput);
|
||||
|
||||
// expand/collapse methods for optional date setters
|
||||
$('.set-date').bind('click', showDateSetter);
|
||||
$('.remove-date').bind('click', removeDateSetter);
|
||||
|
||||
$('.delete-section-button').bind('click', deleteSection);
|
||||
$('.delete-subsection-button').bind('click', deleteSubsection);
|
||||
|
||||
$('.sync-date').bind('click', syncReleaseDate);
|
||||
|
||||
// section date setting
|
||||
$('.set-publish-date').bind('click', setSectionScheduleDate);
|
||||
$('.edit-section-start-cancel').bind('click', cancelSetSectionScheduleDate);
|
||||
|
||||
$body.on('change', '.edit-subsection-publish-settings .start-date', function() {
|
||||
if ($('.edit-subsection-publish-settings').find('.start-time').val() == '') {
|
||||
$('.edit-subsection-publish-settings').find('.start-time').val('12:00am');
|
||||
}
|
||||
});
|
||||
$('.edit-subsection-publish-settings').on('change', '.start-date, .start-time', function() {
|
||||
$('.edit-subsection-publish-settings').find('.save-button').show();
|
||||
});
|
||||
|
||||
IframeUtils.iframeBinding();
|
||||
|
||||
// disable ajax caching in IE so that backbone fetches work
|
||||
if ($.browser.msie) {
|
||||
$.ajaxSetup({ cache: false });
|
||||
}
|
||||
});
|
||||
|
||||
function smoothScrollLink(e) {
|
||||
@@ -143,183 +104,6 @@ function smoothScrollTop(e) {
|
||||
});
|
||||
}
|
||||
|
||||
function linkNewWindow(e) {
|
||||
window.open($(e.target).attr('href'));
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function syncReleaseDate(e) {
|
||||
e.preventDefault();
|
||||
$(this).closest('.notice').hide();
|
||||
$("#start_date").val("");
|
||||
$("#start_time").val("");
|
||||
}
|
||||
|
||||
|
||||
function autosaveInput(e) {
|
||||
var self = this;
|
||||
if (this.saveTimer) {
|
||||
clearTimeout(this.saveTimer);
|
||||
}
|
||||
|
||||
this.saveTimer = setTimeout(function() {
|
||||
$changedInput = $(e.target);
|
||||
saveSubsection();
|
||||
self.saveTimer = null;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function saveSubsection() {
|
||||
// Spinner is no longer used by subsection name, but is still used by date and time pickers on the right.
|
||||
if ($changedInput && !$changedInput.hasClass('no-spinner')) {
|
||||
$spinner.css({
|
||||
'position': 'absolute',
|
||||
'top': Math.floor($changedInput.position().top + ($changedInput.outerHeight() / 2) + 3),
|
||||
'left': $changedInput.position().left + $changedInput.outerWidth() - 24,
|
||||
'margin-top': '-10px'
|
||||
});
|
||||
$changedInput.after($spinner);
|
||||
$spinner.show();
|
||||
}
|
||||
|
||||
var locator = $('.subsection-body').data('locator');
|
||||
|
||||
// pull all 'normalized' metadata editable fields on page
|
||||
var metadata_fields = $('input[data-metadata-name]');
|
||||
|
||||
var metadata = {};
|
||||
for (var i = 0; i < metadata_fields.length; i++) {
|
||||
var el = metadata_fields[i];
|
||||
metadata[$(el).data("metadata-name")] = el.value;
|
||||
}
|
||||
|
||||
// get datetimes for start and due, stick into metadata
|
||||
_(["start", "due"]).each(function(name) {
|
||||
|
||||
var datetime = DateUtils(
|
||||
document.getElementById(name+"_date"),
|
||||
document.getElementById(name+"_time")
|
||||
);
|
||||
// if datetime is null, we want to set that in metadata anyway;
|
||||
// its an indication to the server to clear the datetime in the DB
|
||||
metadata[name] = datetime;
|
||||
});
|
||||
|
||||
$.ajax({
|
||||
url: ModuleUtils.getUpdateUrl(locator),
|
||||
type: "PUT",
|
||||
dataType: "json",
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify({
|
||||
'metadata': metadata
|
||||
}),
|
||||
success: function() {
|
||||
$spinner.delay(500).fadeOut(150);
|
||||
$changedInput = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function createNewUnit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var parent = $(this).data('parent');
|
||||
var category = $(this).data('category');
|
||||
|
||||
analytics.track('Created a Unit', {
|
||||
'course': course_location_analytics,
|
||||
'parent_locator': parent
|
||||
});
|
||||
|
||||
|
||||
$.postJSON(ModuleUtils.getUpdateUrl(), {
|
||||
'parent_locator': parent,
|
||||
'category': category,
|
||||
'display_name': 'New Unit'
|
||||
},
|
||||
|
||||
function(data) {
|
||||
// redirect to the edit page
|
||||
window.location = "/unit/" + data['locator'];
|
||||
});
|
||||
}
|
||||
|
||||
function deleteUnit(e) {
|
||||
e.preventDefault();
|
||||
_deleteItem($(this).parents('li.courseware-unit'), 'Unit');
|
||||
}
|
||||
|
||||
function deleteSubsection(e) {
|
||||
e.preventDefault();
|
||||
_deleteItem($(this).parents('li.courseware-subsection'), 'Subsection');
|
||||
}
|
||||
|
||||
function deleteSection(e) {
|
||||
e.preventDefault();
|
||||
_deleteItem($(this).parents('section.courseware-section'), 'Section');
|
||||
}
|
||||
|
||||
function _deleteItem($el, type) {
|
||||
var confirm = new PromptView.Warning({
|
||||
title: gettext('Delete this ' + type + '?'),
|
||||
message: gettext('Deleting this ' + type + ' is permanent and cannot be undone.'),
|
||||
actions: {
|
||||
primary: {
|
||||
text: gettext('Yes, delete this ' + type),
|
||||
click: function(view) {
|
||||
view.hide();
|
||||
|
||||
var locator = $el.data('locator');
|
||||
|
||||
analytics.track('Deleted an Item', {
|
||||
'course': course_location_analytics,
|
||||
'id': locator
|
||||
});
|
||||
|
||||
var deleting = new NotificationView.Mini({
|
||||
title: gettext('Deleting…')
|
||||
});
|
||||
deleting.show();
|
||||
|
||||
$.ajax({
|
||||
type: 'DELETE',
|
||||
url: ModuleUtils.getUpdateUrl(locator),
|
||||
success: function () {
|
||||
$el.remove();
|
||||
deleting.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
secondary: {
|
||||
text: gettext('Cancel'),
|
||||
click: function(view) {
|
||||
view.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
confirm.show();
|
||||
}
|
||||
|
||||
function showDateSetter(e) {
|
||||
e.preventDefault();
|
||||
var $block = $(this).closest('.due-date-input');
|
||||
$(this).hide();
|
||||
$block.find('.date-setter').show();
|
||||
}
|
||||
|
||||
function removeDateSetter(e) {
|
||||
e.preventDefault();
|
||||
var $block = $(this).closest('.due-date-input');
|
||||
$block.find('.date-setter').hide();
|
||||
$block.find('.set-date').show();
|
||||
// clear out the values
|
||||
$block.find('.date').val('');
|
||||
$block.find('.time').val('');
|
||||
}
|
||||
|
||||
function hideNotification(e) {
|
||||
(e).preventDefault();
|
||||
$(this).closest('.wrapper-notification').removeClass('is-shown').addClass('is-hiding').attr('aria-hidden', 'true');
|
||||
@@ -330,18 +114,4 @@ function hideAlert(e) {
|
||||
$(this).closest('.wrapper-alert').removeClass('is-shown');
|
||||
}
|
||||
|
||||
function setSectionScheduleDate(e) {
|
||||
e.preventDefault();
|
||||
$(this).closest("h4").hide();
|
||||
$(this).parent().siblings(".datepair").show();
|
||||
}
|
||||
|
||||
function cancelSetSectionScheduleDate(e) {
|
||||
e.preventDefault();
|
||||
$(this).closest(".datepair").hide();
|
||||
$(this).parent().siblings("h4").show();
|
||||
}
|
||||
|
||||
window.deleteSection = deleteSection;
|
||||
|
||||
}); // end require()
|
||||
|
||||
@@ -1,17 +1,163 @@
|
||||
define(["backbone", "js/utils/module"], function(Backbone, ModuleUtils) {
|
||||
define(
|
||||
['backbone', 'underscore', 'underscore.string', 'js/utils/module'],
|
||||
function(Backbone, _, str, ModuleUtils) {
|
||||
'use strict';
|
||||
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_draft": null,
|
||||
"is_container": null,
|
||||
"data": null,
|
||||
"metadata" : null,
|
||||
"children": null
|
||||
'id': null,
|
||||
'display_name': null,
|
||||
'category': null,
|
||||
'data': null,
|
||||
'metadata' : 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,
|
||||
/**
|
||||
* Date of the last edit to this xblock or any of its descendants.
|
||||
*/
|
||||
'edited_on':null,
|
||||
/**
|
||||
* User who last edited the xblock or any of its descendants.
|
||||
*/
|
||||
'edited_by':null,
|
||||
/**
|
||||
* True iff a published version of the xblock exists.
|
||||
*/
|
||||
"published": null,
|
||||
/**
|
||||
* Date of the last publish of this xblock, or null if never published.
|
||||
*/
|
||||
'published_on': null,
|
||||
/**
|
||||
* User who last published the xblock, or null if never published.
|
||||
*/
|
||||
'published_by': null,
|
||||
/**
|
||||
* True if the xblock has changes.
|
||||
* Note: this is not always provided as a performance optimization. It is only provided for
|
||||
* verticals functioning as units.
|
||||
*/
|
||||
"has_changes": null,
|
||||
/**
|
||||
* Represents the possible publish states for an xblock. See the documentation
|
||||
* for XBlockVisibility to see a comprehensive enumeration of the states.
|
||||
*/
|
||||
"visibility_state": null,
|
||||
/**
|
||||
* True iff the release date of the xblock is in the past.
|
||||
*/
|
||||
'released_to_students': null,
|
||||
/**
|
||||
* If the xblock is published, the date on which it will be released to students.
|
||||
* This can be null if the release date is unscheduled.
|
||||
*/
|
||||
'release_date': null,
|
||||
/**
|
||||
* The xblock which is determining the release date. For instance, for a unit,
|
||||
* this will either be the parent subsection or the grandparent section.
|
||||
* This can be null if the release date is unscheduled.
|
||||
*/
|
||||
'release_date_from':null,
|
||||
/**
|
||||
* True if this xblock is currently visible to students. This is computed server-side
|
||||
* so that the logic isn't duplicated on the client.
|
||||
*/
|
||||
'currently_visible_to_students': null,
|
||||
/**
|
||||
* If xblock is graded, the date after which student assessment will be evaluated.
|
||||
* It has same format as release date, for example: 'Jan 02, 2015 at 00:00 UTC'.
|
||||
*/
|
||||
'due_date': null,
|
||||
/**
|
||||
* Grading policy for xblock.
|
||||
*/
|
||||
'format': null,
|
||||
/**
|
||||
* List of course graders names.
|
||||
*/
|
||||
'course_graders': null,
|
||||
/**
|
||||
* True if this xblock contributes to the final course grade.
|
||||
*/
|
||||
'graded': null,
|
||||
/**
|
||||
* The same as `release_date` but as an ISO-formatted date string.
|
||||
*/
|
||||
'start': null,
|
||||
/**
|
||||
* The same as `due_date` but as an ISO-formatted date string.
|
||||
*/
|
||||
'due': null
|
||||
},
|
||||
|
||||
initialize: function () {
|
||||
// Extend our Model by helper methods.
|
||||
_.extend(this, this.getCategoryHelpers());
|
||||
},
|
||||
|
||||
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 a list of convenience methods to check affiliation to the category.
|
||||
* @return {Array}
|
||||
*/
|
||||
getCategoryHelpers: function () {
|
||||
var categories = ['course', 'chapter', 'sequential', 'vertical'],
|
||||
helpers = {};
|
||||
|
||||
_.each(categories, function (item) {
|
||||
helpers['is' + str.titleize(item)] = function () {
|
||||
return this.get('category') === item;
|
||||
};
|
||||
}, this);
|
||||
|
||||
return helpers;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if we can edit current XBlock or not on Course Outline page.
|
||||
* @return {Boolean}
|
||||
*/
|
||||
isEditableOnCourseOutline: function() {
|
||||
return this.isSequential() || this.isChapter();
|
||||
}
|
||||
});
|
||||
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;
|
||||
});
|
||||
12
cms/static/js/spec/models/xblock_info_spec.js
Normal file
12
cms/static/js/spec/models/xblock_info_spec.js
Normal file
@@ -0,0 +1,12 @@
|
||||
define(['backbone', 'js/models/xblock_info'],
|
||||
function(Backbone, XBlockInfo) {
|
||||
describe('XblockInfo isEditableOnCourseOutline', function() {
|
||||
it('works correct', function() {
|
||||
expect(new XBlockInfo({'category': 'chapter'}).isEditableOnCourseOutline()).toBe(true);
|
||||
expect(new XBlockInfo({'category': 'course'}).isEditableOnCourseOutline()).toBe(false);
|
||||
expect(new XBlockInfo({'category': 'sequential'}).isEditableOnCourseOutline()).toBe(true);
|
||||
expect(new XBlockInfo({'category': 'vertical'}).isEditableOnCourseOutline()).toBe(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,10 +1,32 @@
|
||||
define(["js/utils/drag_and_drop", "js/views/feedback_notification", "js/spec_helpers/create_sinon", "jquery"],
|
||||
function (ContentDragger, Notification, create_sinon, $) {
|
||||
define(["js/utils/drag_and_drop", "js/views/feedback_notification", "js/spec_helpers/create_sinon", "jquery", "underscore"],
|
||||
function (ContentDragger, Notification, create_sinon, $, _) {
|
||||
describe("Overview drag and drop functionality", function () {
|
||||
beforeEach(function () {
|
||||
setFixtures(readFixtures('mock/mock-outline.underscore'));
|
||||
ContentDragger.makeDraggable('.unit', '.unit-drag-handle', 'ol.sortable-unit-list', 'li.courseware-subsection, article.subsection-body');
|
||||
ContentDragger.makeDraggable('.courseware-subsection', '.subsection-drag-handle', '.sortable-subsection-list', 'section');
|
||||
_.each(
|
||||
$('.unit'),
|
||||
function (element) {
|
||||
ContentDragger.makeDraggable(element, {
|
||||
type: '.unit',
|
||||
handleClass: '.unit-drag-handle',
|
||||
droppableClass: 'ol.sortable-unit-list',
|
||||
parentLocationSelector: 'li.courseware-subsection',
|
||||
refresh: jasmine.createSpy('Spy on Unit')
|
||||
});
|
||||
}
|
||||
);
|
||||
_.each(
|
||||
$('.courseware-subsection'),
|
||||
function (element) {
|
||||
ContentDragger.makeDraggable(element, {
|
||||
type: '.courseware-subsection',
|
||||
handleClass: '.subsection-drag-handle',
|
||||
droppableClass: '.sortable-subsection-list',
|
||||
parentLocationSelector: 'section',
|
||||
refresh: jasmine.createSpy('Spy on Subsection')
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("findDestination", function () {
|
||||
@@ -115,7 +137,7 @@ define(["js/utils/drag_and_drop", "js/views/feedback_notification", "js/spec_hel
|
||||
});
|
||||
it("can drag into a collapsed list", function () {
|
||||
var $ele, destination;
|
||||
$('#subsection-2').addClass('collapsed');
|
||||
$('#subsection-2').addClass('is-collapsed');
|
||||
$ele = $('#unit-2');
|
||||
$ele.offset({
|
||||
top: $('#subsection-2').offset().top + 3,
|
||||
@@ -142,17 +164,17 @@ define(["js/utils/drag_and_drop", "js/views/feedback_notification", "js/spec_hel
|
||||
});
|
||||
});
|
||||
it("collapses expanded elements", function () {
|
||||
expect($('#subsection-1')).not.toHaveClass('collapsed');
|
||||
expect($('#subsection-1')).not.toHaveClass('is-collapsed');
|
||||
ContentDragger.onDragStart({
|
||||
element: $('#subsection-1')
|
||||
}, null, null);
|
||||
expect($('#subsection-1')).toHaveClass('collapsed');
|
||||
expect($('#subsection-1')).toHaveClass('is-collapsed');
|
||||
expect($('#subsection-1')).toHaveClass('expand-on-drop');
|
||||
});
|
||||
});
|
||||
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 +221,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 +229,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 () {
|
||||
@@ -219,7 +241,7 @@ define(["js/utils/drag_and_drop", "js/views/feedback_notification", "js/spec_hel
|
||||
});
|
||||
it("calls handleReorder on a successful drag", function () {
|
||||
ContentDragger.dragState.dropDestination = $('#unit-2');
|
||||
ContentDragger.dragState.attachMethod = "before";
|
||||
ContentDragger.dragState.attachMethod = "after";
|
||||
ContentDragger.dragState.parentList = $('#subsection-1');
|
||||
$('#unit-1').offset({
|
||||
top: $('#unit-1').offset().top + 10,
|
||||
@@ -246,16 +268,16 @@ define(["js/utils/drag_and_drop", "js/views/feedback_notification", "js/spec_hel
|
||||
expect(['0px', 'auto']).toContain($('#unit-1').css('left'));
|
||||
});
|
||||
it("expands an element if it was collapsed on drag start", function () {
|
||||
$('#subsection-1').addClass('collapsed');
|
||||
$('#subsection-1').addClass('is-collapsed');
|
||||
$('#subsection-1').addClass('expand-on-drop');
|
||||
ContentDragger.onDragEnd({
|
||||
element: $('#subsection-1')
|
||||
}, null, null);
|
||||
expect($('#subsection-1')).not.toHaveClass('collapsed');
|
||||
expect($('#subsection-1')).not.toHaveClass('is-collapsed');
|
||||
expect($('#subsection-1')).not.toHaveClass('expand-on-drop');
|
||||
});
|
||||
it("expands a collapsed element when something is dropped in it", function () {
|
||||
$('#subsection-2').addClass('collapsed');
|
||||
$('#subsection-2').addClass('is-collapsed');
|
||||
ContentDragger.dragState.dropDestination = $('#list-2');
|
||||
ContentDragger.dragState.attachMethod = "prepend";
|
||||
ContentDragger.dragState.parentList = $('#subsection-2');
|
||||
@@ -264,7 +286,7 @@ define(["js/utils/drag_and_drop", "js/views/feedback_notification", "js/spec_hel
|
||||
}, null, {
|
||||
clientX: $('#unit-1').offset().left
|
||||
});
|
||||
expect($('#subsection-2')).not.toHaveClass('collapsed');
|
||||
expect($('#subsection-2')).not.toHaveClass('is-collapsed');
|
||||
});
|
||||
});
|
||||
describe("AJAX", function () {
|
||||
@@ -276,7 +298,7 @@ define(["js/utils/drag_and_drop", "js/views/feedback_notification", "js/spec_hel
|
||||
afterEach(function () {
|
||||
this.clock.restore();
|
||||
});
|
||||
it("should send an update on reorder", function () {
|
||||
it("should send an update on reorder from one parent to another", function () {
|
||||
var requests, savingOptions;
|
||||
requests = create_sinon["requests"](this);
|
||||
ContentDragger.dragState.dropDestination = $('#unit-4');
|
||||
@@ -306,6 +328,35 @@ define(["js/utils/drag_and_drop", "js/views/feedback_notification", "js/spec_hel
|
||||
expect(this.savingSpies.hide).toHaveBeenCalled();
|
||||
this.clock.tick(1001);
|
||||
expect($('#unit-1')).not.toHaveClass('was-dropped');
|
||||
// source
|
||||
expect($('#subsection-1').data('refresh')).toHaveBeenCalled();
|
||||
// target
|
||||
expect($('#subsection-2').data('refresh')).toHaveBeenCalled();
|
||||
});
|
||||
it("should send an update on reorder within the same parent", function () {
|
||||
var requests = create_sinon["requests"](this);
|
||||
ContentDragger.dragState.dropDestination = $('#unit-2');
|
||||
ContentDragger.dragState.attachMethod = "after";
|
||||
ContentDragger.dragState.parentList = $('#subsection-1');
|
||||
$('#unit-1').offset({
|
||||
top: $('#unit-1').offset().top + 10,
|
||||
left: $('#unit-1').offset().left
|
||||
});
|
||||
ContentDragger.onDragEnd({
|
||||
element: $('#unit-1')
|
||||
}, null, {
|
||||
clientX: $('#unit-1').offset().left
|
||||
});
|
||||
expect(requests.length).toEqual(1);
|
||||
expect($('#unit-1')).toHaveClass('was-dropped');
|
||||
expect(requests[0].requestBody).toEqual(
|
||||
'{"children":["second-unit-id","first-unit-id","third-unit-id"]}'
|
||||
);
|
||||
requests[0].respond(200);
|
||||
this.clock.tick(1001);
|
||||
expect($('#unit-1')).not.toHaveClass('was-dropped');
|
||||
// parent
|
||||
expect($('#subsection-1').data('refresh')).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers",
|
||||
define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers",
|
||||
"js/views/container", "js/models/xblock_info", "jquery.simulate",
|
||||
"xmodule", "coffee/src/main", "xblock/cms.runtime.v1"],
|
||||
function ($, create_sinon, view_helpers, ContainerView, XBlockInfo) {
|
||||
function ($, create_sinon, edit_helpers, ContainerView, XBlockInfo) {
|
||||
|
||||
describe("Container View", function () {
|
||||
|
||||
@@ -34,9 +34,10 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers
|
||||
};
|
||||
|
||||
beforeEach(function () {
|
||||
view_helpers.installViewTemplates();
|
||||
edit_helpers.installMockXBlock();
|
||||
edit_helpers.installViewTemplates();
|
||||
appendSetFixtures('<div class="wrapper-xblock level-page studio-xblock-wrapper" data-locator="' + rootLocator + '"></div>');
|
||||
notificationSpy = view_helpers.createNotificationSpy();
|
||||
notificationSpy = edit_helpers.createNotificationSpy();
|
||||
model = new XBlockInfo({
|
||||
id: rootLocator,
|
||||
display_name: 'Test AB Test',
|
||||
@@ -51,6 +52,7 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
edit_helpers.uninstallMockXBlock();
|
||||
containerView.remove();
|
||||
});
|
||||
|
||||
@@ -186,11 +188,11 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers
|
||||
|
||||
// Drag the first component in Group B to the first group.
|
||||
dragComponentAbove(groupBComponent1, groupAComponent1);
|
||||
view_helpers.verifyNotificationShowing(notificationSpy, 'Saving');
|
||||
edit_helpers.verifyNotificationShowing(notificationSpy, 'Saving');
|
||||
respondToRequest(requests, 0, 200);
|
||||
view_helpers.verifyNotificationShowing(notificationSpy, 'Saving');
|
||||
edit_helpers.verifyNotificationShowing(notificationSpy, 'Saving');
|
||||
respondToRequest(requests, 1, 200);
|
||||
view_helpers.verifyNotificationHidden(notificationSpy);
|
||||
edit_helpers.verifyNotificationHidden(notificationSpy);
|
||||
});
|
||||
|
||||
it('does not hide saving message if failure', function () {
|
||||
@@ -198,9 +200,9 @@ define([ "jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers
|
||||
|
||||
// Drag the first component in Group B to the first group.
|
||||
dragComponentAbove(groupBComponent1, groupAComponent1);
|
||||
view_helpers.verifyNotificationShowing(notificationSpy, 'Saving');
|
||||
edit_helpers.verifyNotificationShowing(notificationSpy, 'Saving');
|
||||
respondToRequest(requests, 0, 500);
|
||||
view_helpers.verifyNotificationShowing(notificationSpy, 'Saving');
|
||||
edit_helpers.verifyNotificationShowing(notificationSpy, 'Saving');
|
||||
|
||||
// Since the first reorder call failed, the removal will not be called.
|
||||
verifyNumReorderCalls(requests, 1);
|
||||
|
||||
@@ -1,29 +1,40 @@
|
||||
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"],
|
||||
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,
|
||||
model, containerPage, requests,
|
||||
model, containerPage, requests, initialDisplayName,
|
||||
mockContainerPage = readFixtures('mock/mock-container-page.underscore'),
|
||||
mockContainerXBlockHtml = readFixtures('mock/mock-container-xblock.underscore'),
|
||||
mockUpdatedContainerXBlockHtml = readFixtures('mock/mock-updated-container-xblock.underscore'),
|
||||
mockXBlockEditorHtml = readFixtures('mock/mock-xblock-editor.underscore');
|
||||
|
||||
beforeEach(function () {
|
||||
var newDisplayName = 'New Display Name';
|
||||
|
||||
edit_helpers.installEditTemplates();
|
||||
edit_helpers.installTemplate('xblock-string-field-editor');
|
||||
appendSetFixtures(mockContainerPage);
|
||||
|
||||
edit_helpers.installMockXBlock({
|
||||
data: "<p>Some HTML</p>",
|
||||
metadata: {
|
||||
display_name: newDisplayName
|
||||
}
|
||||
});
|
||||
|
||||
initialDisplayName = 'Test Container';
|
||||
|
||||
model = new XBlockInfo({
|
||||
id: 'locator-container',
|
||||
display_name: 'Test Container',
|
||||
display_name: initialDisplayName,
|
||||
category: 'vertical'
|
||||
});
|
||||
containerPage = new ContainerPage({
|
||||
model: model,
|
||||
templates: edit_helpers.mockComponentTemplates,
|
||||
el: $('#content')
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
edit_helpers.uninstallMockXBlock();
|
||||
});
|
||||
|
||||
lastRequest = function() { return requests[requests.length - 1]; };
|
||||
@@ -37,8 +48,13 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
|
||||
);
|
||||
};
|
||||
|
||||
renderContainerPage = function(html, that) {
|
||||
requests = create_sinon.requests(that);
|
||||
renderContainerPage = function(test, html, options) {
|
||||
requests = create_sinon.requests(test);
|
||||
containerPage = new ContainerPage(_.extend(options || {}, {
|
||||
model: model,
|
||||
templates: edit_helpers.mockComponentTemplates,
|
||||
el: $('#content')
|
||||
}));
|
||||
containerPage.render();
|
||||
respondWithHtml(html);
|
||||
};
|
||||
@@ -54,10 +70,9 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
|
||||
|
||||
describe("Initial display", function() {
|
||||
it('can render itself', function() {
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
expect(containerPage.$el.select('.xblock-header')).toBeTruthy();
|
||||
expect(containerPage.$('.wrapper-xblock')).not.toHaveClass('is-hidden');
|
||||
expect(containerPage.$('.no-container-content')).toHaveClass('is-hidden');
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
expect(containerPage.$('.xblock-header').length).toBe(9);
|
||||
expect(containerPage.$('.wrapper-xblock .level-nesting')).not.toHaveClass('is-hidden');
|
||||
});
|
||||
|
||||
it('shows a loading indicator', function() {
|
||||
@@ -67,29 +82,33 @@ 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(this, mockContainerXBlockHtml, {
|
||||
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 newDisplayName = 'New Display Name';
|
||||
|
||||
beforeEach(function () {
|
||||
edit_helpers.installMockXBlock({
|
||||
data: "<p>Some HTML</p>",
|
||||
metadata: {
|
||||
display_name: newDisplayName
|
||||
}
|
||||
});
|
||||
});
|
||||
var updatedDisplayName = 'Updated Test Container',
|
||||
getDisplayNameWrapper;
|
||||
|
||||
afterEach(function() {
|
||||
edit_helpers.uninstallMockXBlock();
|
||||
edit_helpers.cancelModalIfShowing();
|
||||
});
|
||||
|
||||
getDisplayNameWrapper = function() {
|
||||
return containerPage.$('.wrapper-xblock-field');
|
||||
};
|
||||
|
||||
it('can edit itself', function() {
|
||||
var editButtons,
|
||||
updatedTitle = 'Updated Test Container';
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
var editButtons, displayNameElement;
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
displayNameElement = containerPage.$('.page-header-title');
|
||||
|
||||
// Click the root edit button
|
||||
editButtons = containerPage.$('.nav-actions .edit-button');
|
||||
@@ -112,38 +131,40 @@ 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: []
|
||||
});
|
||||
|
||||
// Expect the title and breadcrumb to be updated
|
||||
expect(containerPage.$('.page-header-title').text().trim()).toBe(updatedTitle);
|
||||
expect(containerPage.$('.page-header .subtitle a').last().text().trim()).toBe(updatedTitle);
|
||||
// Expect the title to have been updated
|
||||
expect(displayNameElement.text().trim()).toBe(updatedDisplayName);
|
||||
});
|
||||
|
||||
it('can inline edit the display name', function() {
|
||||
var displayNameInput, displayNameWrapper;
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
displayNameWrapper = getDisplayNameWrapper();
|
||||
displayNameInput = edit_helpers.inlineEdit(displayNameWrapper, 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});
|
||||
edit_helpers.verifyInlineEditChange(displayNameWrapper, updatedDisplayName);
|
||||
expect(containerPage.model.get('display_name')).toBe(updatedDisplayName);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Editing an xblock", function() {
|
||||
var newDisplayName = 'New Display Name';
|
||||
|
||||
beforeEach(function () {
|
||||
edit_helpers.installMockXBlock({
|
||||
data: "<p>Some HTML</p>",
|
||||
metadata: {
|
||||
display_name: newDisplayName
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
edit_helpers.uninstallMockXBlock();
|
||||
edit_helpers.cancelModalIfShowing();
|
||||
});
|
||||
|
||||
it('can show an edit modal for a child xblock', function() {
|
||||
var editButtons;
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
editButtons = containerPage.$('.wrapper-xblock .edit-button');
|
||||
// The container should have rendered six mock xblocks
|
||||
expect(editButtons.length).toBe(6);
|
||||
@@ -179,7 +200,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
|
||||
it('can save changes to settings', function() {
|
||||
var editButtons, modal, mockUpdatedXBlockHtml;
|
||||
mockUpdatedXBlockHtml = readFixtures('mock/mock-updated-xblock.underscore');
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
editButtons = containerPage.$('.wrapper-xblock .edit-button');
|
||||
// The container should have rendered six mock xblocks
|
||||
expect(editButtons.length).toBe(6);
|
||||
@@ -190,6 +211,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
|
||||
});
|
||||
|
||||
modal = $('.edit-xblock-modal');
|
||||
expect(modal.length).toBe(1);
|
||||
// Click on the settings tab
|
||||
modal.find('.settings-button').click();
|
||||
// Change the display name's text
|
||||
@@ -210,8 +232,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); }
|
||||
@@ -221,19 +243,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) {
|
||||
@@ -245,18 +260,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) {
|
||||
@@ -268,10 +273,8 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
|
||||
new RegExp("locator-component-" + GROUP_TO_TEST + (componentIndex + 1))
|
||||
);
|
||||
|
||||
// second request contains parent's id (to remove as child)
|
||||
expect(lastRequest().url).toMatch(
|
||||
new RegExp("locator-group-" + GROUP_TO_TEST)
|
||||
);
|
||||
// final request to refresh the xblock info
|
||||
create_sinon.expectJsonRequest(requests, 'GET', '/xblock/locator-container');
|
||||
};
|
||||
|
||||
deleteComponentWithSuccess = function(componentIndex) {
|
||||
@@ -285,24 +288,24 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
|
||||
};
|
||||
|
||||
it("can delete the first xblock", function() {
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
deleteComponentWithSuccess(0);
|
||||
});
|
||||
|
||||
it("can delete a middle xblock", function() {
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
deleteComponentWithSuccess(1);
|
||||
});
|
||||
|
||||
it("can delete the last xblock", function() {
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
deleteComponentWithSuccess(NUM_COMPONENTS_PER_GROUP - 1);
|
||||
});
|
||||
|
||||
it('does not delete when clicking No in prompt', function () {
|
||||
var numRequests;
|
||||
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
numRequests = requests.length;
|
||||
|
||||
// click delete on the first component but press no
|
||||
@@ -317,7 +320,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
|
||||
|
||||
it('shows a notification during the delete operation', function() {
|
||||
var notificationSpy = edit_helpers.createNotificationSpy();
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
clickDelete(0);
|
||||
edit_helpers.verifyNotificationShowing(notificationSpy, /Deleting/);
|
||||
create_sinon.respondWithJson(requests, {});
|
||||
@@ -326,7 +329,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
|
||||
|
||||
it('does not delete an xblock upon failure', function () {
|
||||
var notificationSpy = edit_helpers.createNotificationSpy();
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
clickDelete(0);
|
||||
edit_helpers.verifyNotificationShowing(notificationSpy, /Deleting/);
|
||||
create_sinon.respondWithError(requests);
|
||||
@@ -336,13 +339,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
|
||||
@@ -353,60 +352,44 @@ 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();
|
||||
};
|
||||
|
||||
it("can duplicate the first xblock", function() {
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
duplicateComponentWithSuccess(0);
|
||||
});
|
||||
|
||||
it("can duplicate a middle xblock", function() {
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
duplicateComponentWithSuccess(1);
|
||||
});
|
||||
|
||||
it("can duplicate the last xblock", function() {
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
duplicateComponentWithSuccess(NUM_COMPONENTS_PER_GROUP - 1);
|
||||
});
|
||||
|
||||
it('shows a notification when duplicating', function () {
|
||||
var notificationSpy = edit_helpers.createNotificationSpy();
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
clickDuplicate(0);
|
||||
edit_helpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
|
||||
create_sinon.respondWithJson(requests, {"locator": "new_item"});
|
||||
@@ -415,7 +398,8 @@ 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);
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
refreshXBlockSpies = spyOn(containerPage, "refreshXBlock");
|
||||
clickDuplicate(0);
|
||||
edit_helpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
|
||||
create_sinon.respondWithError(requests);
|
||||
@@ -426,14 +410,14 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
|
||||
});
|
||||
|
||||
describe('createNewComponent ', function () {
|
||||
var clickNewComponent, verifyComponents;
|
||||
var clickNewComponent;
|
||||
|
||||
clickNewComponent = function (index) {
|
||||
containerPage.$(".new-component .new-component-type a.single-template")[index].click();
|
||||
};
|
||||
|
||||
it('sends the correct JSON to the server', function () {
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
clickNewComponent(0);
|
||||
edit_helpers.verifyXBlockRequest(requests, {
|
||||
"category": "discussion",
|
||||
@@ -444,7 +428,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
|
||||
|
||||
it('shows a notification while creating', function () {
|
||||
var notificationSpy = edit_helpers.createNotificationSpy();
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
clickNewComponent(0);
|
||||
edit_helpers.verifyNotificationShowing(notificationSpy, /Adding/);
|
||||
create_sinon.respondWithJson(requests, { });
|
||||
@@ -453,7 +437,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
|
||||
|
||||
it('does not insert component upon failure', function () {
|
||||
var requestCount;
|
||||
renderContainerPage(mockContainerXBlockHtml, this);
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
clickNewComponent(0);
|
||||
requestCount = requests.length;
|
||||
create_sinon.respondWithError(requests);
|
||||
@@ -472,7 +456,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin
|
||||
|
||||
verifyCreateHtmlComponent = function(test, templateIndex, expectedRequest) {
|
||||
var xblockCount;
|
||||
renderContainerPage(mockContainerXBlockHtml, test);
|
||||
renderContainerPage(test, mockContainerXBlockHtml);
|
||||
showTemplatePicker();
|
||||
xblockCount = containerPage.$('.studio-xblock-wrapper').length;
|
||||
containerPage.$('.new-component-html a')[templateIndex].click();
|
||||
|
||||
555
cms/static/js/spec/views/pages/container_subviews_spec.js
Normal file
555
cms/static/js/spec/views/pages/container_subviews_spec.js
Normal file
@@ -0,0 +1,555 @@
|
||||
define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers",
|
||||
"js/views/feedback_prompt", "js/views/pages/container", "js/views/pages/container_subviews",
|
||||
"js/models/xblock_info", "js/views/utils/xblock_utils"],
|
||||
function ($, _, str, create_sinon, edit_helpers, Prompt, ContainerPage, ContainerSubviews,
|
||||
XBlockInfo, XBlockUtils) {
|
||||
var VisibilityState = XBlockUtils.VisibilityState;
|
||||
|
||||
describe("Container Subviews", function() {
|
||||
var model, containerPage, requests, createContainerPage, renderContainerPage,
|
||||
respondWithHtml, respondWithJson, fetch,
|
||||
disabledCss = "is-disabled", defaultXBlockInfo, createXBlockInfo,
|
||||
mockContainerPage = readFixtures('mock/mock-container-page.underscore'),
|
||||
mockContainerXBlockHtml = readFixtures('mock/mock-empty-container-xblock.underscore');
|
||||
|
||||
beforeEach(function () {
|
||||
edit_helpers.installTemplate('xblock-string-field-editor');
|
||||
edit_helpers.installTemplate('publish-xblock');
|
||||
edit_helpers.installTemplate('publish-history');
|
||||
edit_helpers.installTemplate('unit-outline');
|
||||
edit_helpers.installTemplate('container-message');
|
||||
appendSetFixtures(mockContainerPage);
|
||||
});
|
||||
|
||||
defaultXBlockInfo = {
|
||||
id: 'locator-container',
|
||||
display_name: 'Test Container',
|
||||
category: 'vertical',
|
||||
published: false,
|
||||
has_changes: false,
|
||||
visibility_state: VisibilityState.unscheduled,
|
||||
edited_on: "Jul 02, 2014 at 14:20 UTC", edited_by: "joe",
|
||||
published_on: "Jul 01, 2014 at 12:45 UTC", published_by: "amako",
|
||||
currently_visible_to_students: false
|
||||
};
|
||||
|
||||
createXBlockInfo = function(options) {
|
||||
return _.extend(_.extend({}, defaultXBlockInfo), options || {});
|
||||
};
|
||||
|
||||
createContainerPage = function (test, options) {
|
||||
requests = create_sinon.requests(test);
|
||||
model = new XBlockInfo(createXBlockInfo(options), { parse: true });
|
||||
containerPage = new ContainerPage({
|
||||
model: model,
|
||||
templates: edit_helpers.mockComponentTemplates,
|
||||
el: $('#content'),
|
||||
isUnitPage: true
|
||||
});
|
||||
};
|
||||
|
||||
renderContainerPage = function (test, html, options) {
|
||||
createContainerPage(test, options);
|
||||
containerPage.render();
|
||||
respondWithHtml(html);
|
||||
};
|
||||
|
||||
respondWithHtml = function(html) {
|
||||
var requestIndex = requests.length - 1;
|
||||
create_sinon.respondWithJson(
|
||||
requests,
|
||||
{ html: html, "resources": [] },
|
||||
requestIndex
|
||||
);
|
||||
};
|
||||
|
||||
respondWithJson = function(json, requestIndex) {
|
||||
create_sinon.respondWithJson(
|
||||
requests,
|
||||
json,
|
||||
requestIndex
|
||||
);
|
||||
};
|
||||
|
||||
fetch = function (json) {
|
||||
json = createXBlockInfo(json);
|
||||
model.fetch();
|
||||
respondWithJson(json);
|
||||
};
|
||||
|
||||
describe("PreviewActionController", function () {
|
||||
var viewPublishedCss = '.button-view',
|
||||
previewCss = '.button-preview';
|
||||
|
||||
it('renders correctly for unscheduled unit', function () {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
expect(containerPage.$(viewPublishedCss)).toHaveClass(disabledCss);
|
||||
expect(containerPage.$(previewCss)).not.toHaveClass(disabledCss);
|
||||
});
|
||||
|
||||
it('updates when publish state changes', function () {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
fetch({published: true});
|
||||
expect(containerPage.$(viewPublishedCss)).not.toHaveClass(disabledCss);
|
||||
|
||||
fetch({published: false});
|
||||
expect(containerPage.$(viewPublishedCss)).toHaveClass(disabledCss);
|
||||
});
|
||||
|
||||
it('updates when has_changes attribute changes', function () {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
fetch({has_changes: true});
|
||||
expect(containerPage.$(previewCss)).not.toHaveClass(disabledCss);
|
||||
|
||||
fetch({published: true, has_changes: false});
|
||||
expect(containerPage.$(previewCss)).toHaveClass(disabledCss);
|
||||
|
||||
// If published is false, preview is always enabled.
|
||||
fetch({published: false, has_changes: false});
|
||||
expect(containerPage.$(previewCss)).not.toHaveClass(disabledCss);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Publisher", function () {
|
||||
var headerCss = '.pub-status',
|
||||
bitPublishingCss = "div.bit-publishing",
|
||||
liveClass = "is-live",
|
||||
readyClass = "is-ready",
|
||||
staffOnlyClass = "is-staff-only",
|
||||
scheduledClass = "is-scheduled",
|
||||
unscheduledClass = "",
|
||||
hasWarningsClass = 'has-warnings',
|
||||
publishButtonCss = ".action-publish",
|
||||
discardChangesButtonCss = ".action-discard",
|
||||
lastDraftCss = ".wrapper-last-draft",
|
||||
releaseDateTitleCss = ".wrapper-release .title",
|
||||
releaseDateContentCss = ".wrapper-release .copy",
|
||||
promptSpies, sendDiscardChangesToServer, verifyPublishingBitUnscheduled;
|
||||
|
||||
sendDiscardChangesToServer = function() {
|
||||
// Helper function to do the discard operation, up until the server response.
|
||||
containerPage.render();
|
||||
respondWithHtml(mockContainerXBlockHtml);
|
||||
fetch({published: true, has_changes: true, visibility_state: VisibilityState.needsAttention});
|
||||
expect(containerPage.$(discardChangesButtonCss)).not.toHaveClass('is-disabled');
|
||||
expect(containerPage.$(bitPublishingCss)).toHaveClass(hasWarningsClass);
|
||||
// Click discard changes
|
||||
containerPage.$(discardChangesButtonCss).click();
|
||||
|
||||
// Confirm the discard.
|
||||
expect(promptSpies.constructor).toHaveBeenCalled();
|
||||
promptSpies.constructor.mostRecentCall.args[0].actions.primary.click(promptSpies);
|
||||
|
||||
create_sinon.expectJsonRequest(requests, "POST", "/xblock/locator-container",
|
||||
{"publish": "discard_changes"}
|
||||
);
|
||||
};
|
||||
|
||||
verifyPublishingBitUnscheduled = function() {
|
||||
expect(containerPage.$(bitPublishingCss)).not.toHaveClass(liveClass);
|
||||
expect(containerPage.$(bitPublishingCss)).not.toHaveClass(readyClass);
|
||||
expect(containerPage.$(bitPublishingCss)).not.toHaveClass(hasWarningsClass);
|
||||
expect(containerPage.$(bitPublishingCss)).not.toHaveClass(staffOnlyClass);
|
||||
expect(containerPage.$(bitPublishingCss)).not.toHaveClass(scheduledClass);
|
||||
expect(containerPage.$(bitPublishingCss)).toHaveClass(unscheduledClass);
|
||||
};
|
||||
|
||||
beforeEach(function() {
|
||||
promptSpies = spyOnConstructor(Prompt, "Warning", ["show", "hide"]);
|
||||
promptSpies.show.andReturn(this.promptSpies);
|
||||
});
|
||||
|
||||
it('renders correctly with private content', function () {
|
||||
var verifyPrivateState = function() {
|
||||
expect(containerPage.$(headerCss).text()).toContain('Draft (Never published)');
|
||||
expect(containerPage.$(publishButtonCss)).not.toHaveClass(disabledCss);
|
||||
expect(containerPage.$(discardChangesButtonCss)).toHaveClass(disabledCss);
|
||||
expect(containerPage.$(bitPublishingCss)).not.toHaveClass(readyClass);
|
||||
expect(containerPage.$(bitPublishingCss)).not.toHaveClass(scheduledClass);
|
||||
expect(containerPage.$(bitPublishingCss)).toHaveClass(hasWarningsClass);
|
||||
};
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
fetch({published: false, has_changes: false, visibility_state: VisibilityState.needsAttention});
|
||||
verifyPrivateState();
|
||||
|
||||
fetch({published: false, has_changes: true, visibility_state: VisibilityState.needsAttention});
|
||||
verifyPrivateState();
|
||||
});
|
||||
|
||||
it('renders correctly with published content', function () {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
fetch({
|
||||
published: true, has_changes: false, visibility_state: VisibilityState.ready,
|
||||
release_date: "Jul 02, 2030 at 14:20 UTC"
|
||||
});
|
||||
expect(containerPage.$(headerCss).text()).toContain('Published (not yet released)');
|
||||
expect(containerPage.$(publishButtonCss)).toHaveClass(disabledCss);
|
||||
expect(containerPage.$(discardChangesButtonCss)).toHaveClass(disabledCss);
|
||||
expect(containerPage.$(bitPublishingCss)).toHaveClass(readyClass);
|
||||
expect(containerPage.$(bitPublishingCss)).toHaveClass(scheduledClass);
|
||||
|
||||
fetch({
|
||||
published: true, has_changes: true, visibility_state: VisibilityState.needsAttention,
|
||||
release_date: "Jul 02, 2030 at 14:20 UTC"
|
||||
});
|
||||
expect(containerPage.$(headerCss).text()).toContain('Draft (Unpublished changes)');
|
||||
expect(containerPage.$(publishButtonCss)).not.toHaveClass(disabledCss);
|
||||
expect(containerPage.$(discardChangesButtonCss)).not.toHaveClass(disabledCss);
|
||||
expect(containerPage.$(bitPublishingCss)).toHaveClass(hasWarningsClass);
|
||||
expect(containerPage.$(bitPublishingCss)).toHaveClass(scheduledClass);
|
||||
|
||||
fetch({published: true, has_changes: false, visibility_state: VisibilityState.live,
|
||||
release_date: "Jul 02, 1990 at 14:20 UTC"
|
||||
});
|
||||
expect(containerPage.$(headerCss).text()).toContain('Published and Live');
|
||||
expect(containerPage.$(publishButtonCss)).toHaveClass(disabledCss);
|
||||
expect(containerPage.$(discardChangesButtonCss)).toHaveClass(disabledCss);
|
||||
expect(containerPage.$(bitPublishingCss)).toHaveClass(liveClass);
|
||||
expect(containerPage.$(bitPublishingCss)).toHaveClass(scheduledClass);
|
||||
|
||||
fetch({published: true, has_changes: false, visibility_state: VisibilityState.unscheduled,
|
||||
release_date: null
|
||||
});
|
||||
expect(containerPage.$(headerCss).text()).toContain('Published (not yet released)');
|
||||
expect(containerPage.$(publishButtonCss)).toHaveClass(disabledCss);
|
||||
expect(containerPage.$(discardChangesButtonCss)).toHaveClass(disabledCss);
|
||||
verifyPublishingBitUnscheduled();
|
||||
});
|
||||
|
||||
it('can publish private content', function () {
|
||||
var notificationSpy = edit_helpers.createNotificationSpy();
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
expect(containerPage.$(bitPublishingCss)).not.toHaveClass(hasWarningsClass);
|
||||
expect(containerPage.$(bitPublishingCss)).not.toHaveClass(readyClass);
|
||||
expect(containerPage.$(bitPublishingCss)).not.toHaveClass(liveClass);
|
||||
|
||||
// Click publish
|
||||
containerPage.$(publishButtonCss).click();
|
||||
edit_helpers.verifyNotificationShowing(notificationSpy, /Publishing/);
|
||||
|
||||
create_sinon.expectJsonRequest(requests, "POST", "/xblock/locator-container",
|
||||
{"publish": "make_public"}
|
||||
);
|
||||
|
||||
// Response to publish call
|
||||
respondWithJson({"id": "locator-container", "data": null, "metadata":{}});
|
||||
edit_helpers.verifyNotificationHidden(notificationSpy);
|
||||
|
||||
create_sinon.expectJsonRequest(requests, "GET", "/xblock/locator-container");
|
||||
// Response to fetch
|
||||
respondWithJson(createXBlockInfo({
|
||||
published: true, has_changes: false, visibility_state: VisibilityState.ready
|
||||
}));
|
||||
|
||||
// Verify updates displayed
|
||||
expect(containerPage.$(bitPublishingCss)).toHaveClass(readyClass);
|
||||
// Verify that the "published" value has been cleared out of the model.
|
||||
expect(containerPage.model.get("publish")).toBeNull();
|
||||
});
|
||||
|
||||
it('does not refresh if publish fails', function () {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
verifyPublishingBitUnscheduled();
|
||||
|
||||
// Click publish
|
||||
containerPage.$(publishButtonCss).click();
|
||||
|
||||
var numRequests = requests.length;
|
||||
// Respond with failure
|
||||
create_sinon.respondWithError(requests);
|
||||
|
||||
expect(requests.length).toEqual(numRequests);
|
||||
|
||||
// Verify still in draft (unscheduled) state.
|
||||
verifyPublishingBitUnscheduled();
|
||||
// Verify that the "published" value has been cleared out of the model.
|
||||
expect(containerPage.model.get("publish")).toBeNull();
|
||||
});
|
||||
|
||||
it('can discard changes', function () {
|
||||
var notificationSpy, renderPageSpy, numRequests;
|
||||
createContainerPage(this);
|
||||
notificationSpy = edit_helpers.createNotificationSpy();
|
||||
renderPageSpy = spyOn(containerPage.xblockPublisher, 'renderPage').andCallThrough();
|
||||
|
||||
sendDiscardChangesToServer();
|
||||
numRequests = requests.length;
|
||||
|
||||
// Respond with success.
|
||||
respondWithJson({"id": "locator-container"});
|
||||
edit_helpers.verifyNotificationHidden(notificationSpy);
|
||||
|
||||
// Verify other requests are sent to the server to update page state.
|
||||
// Response to fetch, specifying the very next request (as multiple requests will be sent to server)
|
||||
expect(requests.length > numRequests).toBeTruthy();
|
||||
expect(containerPage.model.get("publish")).toBeNull();
|
||||
expect(renderPageSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fetch if discard changes fails', function () {
|
||||
var renderPageSpy, numRequests;
|
||||
createContainerPage(this);
|
||||
renderPageSpy = spyOn(containerPage.xblockPublisher, 'renderPage').andCallThrough();
|
||||
|
||||
sendDiscardChangesToServer();
|
||||
numRequests = requests.length;
|
||||
|
||||
// Respond with failure
|
||||
create_sinon.respondWithError(requests);
|
||||
|
||||
expect(requests.length).toEqual(numRequests);
|
||||
expect(containerPage.$(discardChangesButtonCss)).not.toHaveClass('is-disabled');
|
||||
expect(containerPage.model.get("publish")).toBeNull();
|
||||
expect(renderPageSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not discard changes on cancel', function () {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
fetch({published: true, has_changes: true, visibility_state: VisibilityState.needsAttention});
|
||||
var numRequests = requests.length;
|
||||
|
||||
// Click discard changes
|
||||
expect(containerPage.$(discardChangesButtonCss)).not.toHaveClass('is-disabled');
|
||||
containerPage.$(discardChangesButtonCss).click();
|
||||
|
||||
// Click cancel to confirmation.
|
||||
expect(promptSpies.constructor).toHaveBeenCalled();
|
||||
promptSpies.constructor.mostRecentCall.args[0].actions.secondary.click(promptSpies);
|
||||
|
||||
expect(requests.length).toEqual(numRequests);
|
||||
expect(containerPage.$(discardChangesButtonCss)).not.toHaveClass('is-disabled');
|
||||
});
|
||||
|
||||
it('renders the last published date and user when there are no changes', function () {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
fetch({published_on: "Jul 01, 2014 at 12:45 UTC", published_by: "amako"});
|
||||
expect(containerPage.$(lastDraftCss).text()).
|
||||
toContain("Last published Jul 01, 2014 at 12:45 UTC by amako");
|
||||
});
|
||||
|
||||
it('renders the last saved date and user when there are changes', function () {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
fetch({has_changes: true, edited_on: "Jul 02, 2014 at 14:20 UTC", edited_by: "joe"});
|
||||
expect(containerPage.$(lastDraftCss).text()).
|
||||
toContain("Draft saved on Jul 02, 2014 at 14:20 UTC by joe");
|
||||
});
|
||||
|
||||
describe("Release Date", function() {
|
||||
it('renders correctly when unreleased', function () {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
fetch({
|
||||
visibility_state: VisibilityState.ready, released_to_students: false,
|
||||
release_date: "Jul 02, 2014 at 14:20 UTC", release_date_from: 'Section "Week 1"'
|
||||
});
|
||||
expect(containerPage.$(releaseDateTitleCss).text()).toContain("Scheduled:");
|
||||
expect(containerPage.$(releaseDateContentCss).text()).
|
||||
toContain('Jul 02, 2014 at 14:20 UTC with Section "Week 1"');
|
||||
});
|
||||
|
||||
it('renders correctly when released', function () {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
fetch({
|
||||
visibility_state: VisibilityState.live, released_to_students: true,
|
||||
release_date: "Jul 02, 2014 at 14:20 UTC", release_date_from: 'Section "Week 1"'
|
||||
});
|
||||
expect(containerPage.$(releaseDateTitleCss).text()).toContain("Released:");
|
||||
expect(containerPage.$(releaseDateContentCss).text()).
|
||||
toContain('Jul 02, 2014 at 14:20 UTC with Section "Week 1"');
|
||||
});
|
||||
|
||||
it('renders correctly when the release date is not set', function () {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
fetch({
|
||||
visibility_state: VisibilityState.unscheduled, "released_to_students": false,
|
||||
release_date: null, release_date_from: null
|
||||
});
|
||||
expect(containerPage.$(releaseDateTitleCss).text()).toContain("Release:");
|
||||
expect(containerPage.$(releaseDateContentCss).text()).toContain("Unscheduled");
|
||||
});
|
||||
|
||||
it('renders correctly when the unit is not published', function () {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
fetch({
|
||||
visibility_state: VisibilityState.needsAttention, released_to_students: true,
|
||||
release_date: "Jul 02, 2014 at 14:20 UTC", release_date_from: 'Section "Week 1"'
|
||||
});
|
||||
containerPage.xblockPublisher.render();
|
||||
expect(containerPage.$(releaseDateTitleCss).text()).toContain("Release:");
|
||||
expect(containerPage.$(releaseDateContentCss).text()).
|
||||
toContain('Jul 02, 2014 at 14:20 UTC with Section "Week 1"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Content Visibility", function () {
|
||||
var requestStaffOnly, verifyStaffOnly, promptSpy,
|
||||
visibilityTitleCss = '.wrapper-visibility .title';
|
||||
|
||||
requestStaffOnly = function(isStaffOnly) {
|
||||
containerPage.$('.action-staff-lock').click();
|
||||
|
||||
// If removing the staff lock, click 'Yes' to confirm
|
||||
if (!isStaffOnly) {
|
||||
edit_helpers.confirmPrompt(promptSpy);
|
||||
}
|
||||
|
||||
create_sinon.expectJsonRequest(requests, 'POST', '/xblock/locator-container', {
|
||||
publish: 'republish',
|
||||
metadata: { visible_to_staff_only: isStaffOnly ? true : null }
|
||||
});
|
||||
create_sinon.respondWithJson(requests, {
|
||||
data: null,
|
||||
id: "locator-container",
|
||||
metadata: {
|
||||
visible_to_staff_only: isStaffOnly ? true : null
|
||||
}
|
||||
});
|
||||
create_sinon.expectJsonRequest(requests, 'GET', '/xblock/locator-container');
|
||||
create_sinon.respondWithJson(requests, createXBlockInfo({
|
||||
published: containerPage.model.get('published'),
|
||||
visibility_state: isStaffOnly ? VisibilityState.staffOnly : VisibilityState.live,
|
||||
release_date: "Jul 02, 2000 at 14:20 UTC"
|
||||
}));
|
||||
};
|
||||
|
||||
verifyStaffOnly = function(isStaffOnly) {
|
||||
if (isStaffOnly) {
|
||||
expect(containerPage.$('.action-staff-lock i')).toHaveClass('icon-check');
|
||||
expect(containerPage.$('.wrapper-visibility .copy').text()).toBe('Staff Only');
|
||||
expect(containerPage.$(bitPublishingCss)).toHaveClass(staffOnlyClass);
|
||||
expect(containerPage.$(bitPublishingCss)).toHaveClass(scheduledClass);
|
||||
} else {
|
||||
expect(containerPage.$('.action-staff-lock i')).toHaveClass('icon-check-empty');
|
||||
expect(containerPage.$('.wrapper-visibility .copy').text()).toBe('Staff and Students');
|
||||
expect(containerPage.$(bitPublishingCss)).not.toHaveClass(staffOnlyClass);
|
||||
}
|
||||
};
|
||||
|
||||
it("is initially shown to all", function() {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
verifyStaffOnly(false);
|
||||
});
|
||||
|
||||
it("displays 'Is Visible To' when released and published", function() {
|
||||
renderContainerPage(this, mockContainerXBlockHtml, {
|
||||
released_to_students: true,
|
||||
published: true,
|
||||
has_changes: false
|
||||
});
|
||||
expect(containerPage.$(visibilityTitleCss).text()).toContain('Is Visible To');
|
||||
});
|
||||
|
||||
it("displays 'Will Be Visible To' when not released or fully published", function() {
|
||||
renderContainerPage(this, mockContainerXBlockHtml, {
|
||||
released_to_students: false,
|
||||
published: true,
|
||||
has_changes: true
|
||||
});
|
||||
expect(containerPage.$(visibilityTitleCss).text()).toContain('Will Be Visible To')
|
||||
});
|
||||
|
||||
it("can be set to staff only", function() {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
requestStaffOnly(true);
|
||||
verifyStaffOnly(true);
|
||||
});
|
||||
|
||||
it("can remove staff only setting", function() {
|
||||
promptSpy = edit_helpers.createPromptSpy();
|
||||
renderContainerPage(this, mockContainerXBlockHtml, {
|
||||
visibility_state: VisibilityState.staffOnly,
|
||||
release_date: "Jul 02, 2000 at 14:20 UTC"
|
||||
});
|
||||
requestStaffOnly(false);
|
||||
verifyStaffOnly(false);
|
||||
});
|
||||
|
||||
it("does not refresh if removing staff only is canceled", function() {
|
||||
var requestCount;
|
||||
promptSpy = edit_helpers.createPromptSpy();
|
||||
renderContainerPage(this, mockContainerXBlockHtml, {
|
||||
visibility_state: VisibilityState.staffOnly,
|
||||
release_date: "Jul 02, 2000 at 14:20 UTC"
|
||||
});
|
||||
requestCount = requests.length;
|
||||
containerPage.$('.action-staff-lock').click();
|
||||
edit_helpers.confirmPrompt(promptSpy, true); // Click 'No' to cancel
|
||||
expect(requests.length).toBe(requestCount);
|
||||
verifyStaffOnly(true);
|
||||
});
|
||||
|
||||
it("does not refresh when failing to set staff only", function() {
|
||||
var requestCount;
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
containerPage.$('.lock-checkbox').click();
|
||||
requestCount = requests.length;
|
||||
create_sinon.respondWithError(requests);
|
||||
expect(requests.length).toBe(requestCount);
|
||||
verifyStaffOnly(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PublishHistory", function () {
|
||||
var lastPublishCss = ".wrapper-last-publish";
|
||||
|
||||
it('renders never published when the block is unpublished', function () {
|
||||
renderContainerPage(this, mockContainerXBlockHtml, {
|
||||
published: false, published_on: null, published_by: null
|
||||
});
|
||||
expect(containerPage.$(lastPublishCss).text()).toContain("Never published");
|
||||
});
|
||||
|
||||
it('renders the last published date and user when the block is published', function() {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
fetch({
|
||||
published: true, published_on: "Jul 01, 2014 at 12:45 UTC", published_by: "amako"
|
||||
});
|
||||
expect(containerPage.$(lastPublishCss).text()).
|
||||
toContain("Last published Jul 01, 2014 at 12:45 UTC by amako");
|
||||
});
|
||||
|
||||
it('renders correctly when the block is published without publish info', function () {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
fetch({
|
||||
published: true, published_on: null, published_by: null
|
||||
});
|
||||
expect(containerPage.$(lastPublishCss).text()).toContain("Previously published");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Message Area", function() {
|
||||
var messageSelector = '.container-message .warning',
|
||||
warningMessage = 'Caution: The last published version of this unit is live. By publishing changes you will change the student experience.';
|
||||
|
||||
it('is empty for a unit that is not currently visible to students', function() {
|
||||
renderContainerPage(this, mockContainerXBlockHtml, {
|
||||
currently_visible_to_students: false
|
||||
});
|
||||
expect(containerPage.$(messageSelector).text().trim()).toBe('');
|
||||
});
|
||||
|
||||
it('shows a message for a unit that is currently visible to students', function() {
|
||||
renderContainerPage(this, mockContainerXBlockHtml, {
|
||||
currently_visible_to_students: true
|
||||
});
|
||||
expect(containerPage.$(messageSelector).text().trim()).toBe(warningMessage);
|
||||
});
|
||||
|
||||
it('hides the message when the unit is hidden from students', function() {
|
||||
renderContainerPage(this, mockContainerXBlockHtml, {
|
||||
currently_visible_to_students: true
|
||||
});
|
||||
fetch({ currently_visible_to_students: false });
|
||||
expect(containerPage.$(messageSelector).text().trim()).toBe('');
|
||||
});
|
||||
|
||||
it('shows a message when a unit is made visible', function() {
|
||||
renderContainerPage(this, mockContainerXBlockHtml, {
|
||||
currently_visible_to_students: false
|
||||
});
|
||||
fetch({ currently_visible_to_students: true });
|
||||
expect(containerPage.$(messageSelector).text().trim()).toBe(warningMessage);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
619
cms/static/js/spec/views/pages/course_outline_spec.js
Normal file
619
cms/static/js/spec/views/pages/course_outline_spec.js
Normal file
@@ -0,0 +1,619 @@
|
||||
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", "js/utils/date_utils", "js/spec_helpers/edit_helpers"],
|
||||
function ($, create_sinon, view_helpers, ViewUtils, CourseOutlinePage, XBlockOutlineInfo, DateUtils, edit_helpers) {
|
||||
|
||||
describe("CourseOutlinePage", function() {
|
||||
var createCourseOutlinePage, displayNameInput, model, outlinePage, requests,
|
||||
getItemsOfType, getItemHeaders, verifyItemsExpanded, expandItemsAndVerifyState, collapseItemsAndVerifyState,
|
||||
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',
|
||||
course_graders: '["Lab", "Howework"]',
|
||||
child_info: {
|
||||
category: 'vertical',
|
||||
display_name: 'Unit',
|
||||
children: children
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
getItemsOfType = function(type) {
|
||||
return outlinePage.$('.outline-' + type);
|
||||
};
|
||||
|
||||
getItemHeaders = function(type) {
|
||||
return getItemsOfType(type).find('> .' + type + '-header');
|
||||
};
|
||||
|
||||
verifyItemsExpanded = function(type, isExpanded) {
|
||||
var element = getItemsOfType(type);
|
||||
if (isExpanded) {
|
||||
expect(element).not.toHaveClass('is-collapsed');
|
||||
} else {
|
||||
expect(element).toHaveClass('is-collapsed');
|
||||
}
|
||||
};
|
||||
|
||||
expandItemsAndVerifyState = function(type) {
|
||||
getItemHeaders(type).find('.ui-toggle-expansion').click();
|
||||
verifyItemsExpanded(type, true);
|
||||
};
|
||||
|
||||
collapseItemsAndVerifyState = function(type) {
|
||||
getItemHeaders(type).find('.ui-toggle-expansion').click();
|
||||
verifyItemsExpanded(type, false);
|
||||
};
|
||||
|
||||
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');
|
||||
view_helpers.installTemplate('modal-button');
|
||||
view_helpers.installTemplate('basic-modal');
|
||||
view_helpers.installTemplate('edit-outline-item-modal');
|
||||
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: false,
|
||||
published: true,
|
||||
visibility_state: 'unscheduled',
|
||||
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();
|
||||
edit_helpers.cancelModalIfShowing();
|
||||
});
|
||||
|
||||
describe('Initial display', function() {
|
||||
it('can render itself', function() {
|
||||
createCourseOutlinePage(this, mockCourseJSON);
|
||||
expect(outlinePage.$('.list-sections')).toExist();
|
||||
expect(outlinePage.$('.list-subsections')).toExist();
|
||||
expect(outlinePage.$('.list-units')).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() {
|
||||
createCourseOutlinePage(this, mockCourseJSON);
|
||||
verifyItemsExpanded('subsection', false);
|
||||
expect(getItemsOfType('unit')).not.toExist();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Button bar", function() {
|
||||
it('can add a section', function() {
|
||||
createCourseOutlinePage(this, mockEmptyCourseJSON);
|
||||
outlinePage.$('.nav-actions .button-new').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.$('.list-sections li.outline-section').data('locator')).toEqual('mock-section');
|
||||
});
|
||||
|
||||
it('can add a second section', function() {
|
||||
var sectionElements;
|
||||
createCourseOutlinePage(this, mockSingleSectionCourseJSON);
|
||||
outlinePage.$('.nav-actions .button-new').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 = getItemsOfType('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);
|
||||
verifyItemsExpanded('section', true);
|
||||
outlinePage.$('.nav-actions .button-toggle-expand-collapse .collapse-all').click();
|
||||
verifyItemsExpanded('section', false);
|
||||
outlinePage.$('.nav-actions .button-toggle-expand-collapse .expand-all').click();
|
||||
verifyItemsExpanded('section', true);
|
||||
});
|
||||
});
|
||||
|
||||
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 .button-new')).toExist();
|
||||
});
|
||||
|
||||
it('can add a section', function() {
|
||||
createCourseOutlinePage(this, mockEmptyCourseJSON);
|
||||
$('.no-content .button-new').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.$('.list-sections li.outline-section').data('locator')).toEqual('mock-section');
|
||||
});
|
||||
|
||||
it('remains empty if an add fails', function() {
|
||||
var requestCount;
|
||||
createCourseOutlinePage(this, mockEmptyCourseJSON);
|
||||
$('.no-content .button-new').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 .button-new')).toExist();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Section", function() {
|
||||
var getDisplayNameWrapper;
|
||||
|
||||
getDisplayNameWrapper = function() {
|
||||
return getItemHeaders('section').find('.wrapper-xblock-field');
|
||||
};
|
||||
|
||||
it('can be deleted', function() {
|
||||
var promptSpy = view_helpers.createPromptSpy(), requestCount;
|
||||
createCourseOutlinePage(this, createMockCourseJSON('mock-course', 'Mock Course', [
|
||||
createMockSectionJSON('mock-section', 'Mock Section', []),
|
||||
createMockSectionJSON('mock-section-2', 'Mock Section 2', [])
|
||||
]));
|
||||
getItemHeaders('section').find('.delete-button').first().click();
|
||||
view_helpers.confirmPrompt(promptSpy);
|
||||
requestCount = requests.length;
|
||||
create_sinon.expectJsonRequest(requests, 'DELETE', '/xblock/mock-section');
|
||||
create_sinon.respondWithJson(requests, {});
|
||||
expect(requests.length).toBe(requestCount); // No fetch should be performed
|
||||
expect(outlinePage.$('[data-locator="mock-section"]')).not.toExist();
|
||||
expect(outlinePage.$('[data-locator="mock-section-2"]')).toExist();
|
||||
});
|
||||
|
||||
it('can be deleted if it is the only section', function() {
|
||||
var promptSpy = view_helpers.createPromptSpy();
|
||||
createCourseOutlinePage(this, mockSingleSectionCourseJSON);
|
||||
getItemHeaders('section').find('.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 .button-new')).toExist();
|
||||
});
|
||||
|
||||
it('remains visible if its deletion fails', function() {
|
||||
var promptSpy = view_helpers.createPromptSpy(),
|
||||
requestCount;
|
||||
createCourseOutlinePage(this, mockSingleSectionCourseJSON);
|
||||
getItemHeaders('section').find('.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.$('.list-sections li.outline-section').data('locator')).toEqual('mock-section');
|
||||
});
|
||||
|
||||
it('can add a subsection', function() {
|
||||
createCourseOutlinePage(this, mockCourseJSON);
|
||||
getItemsOfType('section').find('> .outline-content > .add-subsection .button-new').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',
|
||||
displayNameWrapper,
|
||||
sectionModel;
|
||||
createCourseOutlinePage(this, mockCourseJSON);
|
||||
displayNameWrapper = getDisplayNameWrapper();
|
||||
displayNameInput = view_helpers.inlineEdit(displayNameWrapper, 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(displayNameWrapper, 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);
|
||||
collapseItemsAndVerifyState('section');
|
||||
expandItemsAndVerifyState('section');
|
||||
collapseItemsAndVerifyState('section');
|
||||
});
|
||||
|
||||
it('can be edited', function() {
|
||||
createCourseOutlinePage(this, mockCourseJSON, false);
|
||||
outlinePage.$('.section-header-actions .configure-button').click();
|
||||
$("#start_date").val("1/2/2015");
|
||||
// Section release date can't be cleared.
|
||||
expect($(".edit-outline-item-modal .action-clear")).not.toExist();
|
||||
|
||||
// Section does not contain due_date or grading type selector
|
||||
expect($("due_date")).not.toExist();
|
||||
expect($("grading_format")).not.toExist();
|
||||
|
||||
$(".edit-outline-item-modal .action-save").click();
|
||||
|
||||
create_sinon.expectJsonRequest(requests, 'POST', '/xblock/mock-section', {
|
||||
"metadata":{
|
||||
"start":"2015-01-02T00:00:00.000Z",
|
||||
}
|
||||
});
|
||||
expect(requests[0].requestHeaders['X-HTTP-Method-Override']).toBe('PATCH');
|
||||
|
||||
// This is the response for the change operation.
|
||||
create_sinon.respondWithJson(requests, {});
|
||||
var mockResponseSectionJSON = $.extend(true, {},
|
||||
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'
|
||||
}
|
||||
])
|
||||
]),
|
||||
{
|
||||
release_date: 'Jan 02, 2015 at 00:00 UTC',
|
||||
}
|
||||
);
|
||||
create_sinon.expectJsonRequest(requests, 'GET', '/xblock/outline/mock-section')
|
||||
expect(requests.length).toBe(2);
|
||||
// This is the response for the subsequent fetch operation for the section.
|
||||
create_sinon.respondWithJson(requests, mockResponseSectionJSON);
|
||||
|
||||
expect($(".outline-section .status-release-value")).toContainText("Jan 02, 2015 at 00:00 UTC");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Subsection", function() {
|
||||
var getDisplayNameWrapper, setEditModalValues, mockServerValuesJson;
|
||||
|
||||
getDisplayNameWrapper = function() {
|
||||
return getItemHeaders('subsection').find('.wrapper-xblock-field');
|
||||
};
|
||||
|
||||
setEditModalValues = function (start_date, due_date, grading_type) {
|
||||
$("#start_date").val(start_date);
|
||||
$("#due_date").val(due_date);
|
||||
$("#grading_type").val(grading_type);
|
||||
}
|
||||
|
||||
// Contains hard-coded dates because dates are presented in different formats.
|
||||
var mockServerValuesJson = $.extend(true, {},
|
||||
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'
|
||||
}
|
||||
])
|
||||
]),
|
||||
{
|
||||
release_date: 'Jan 01, 2970 at 05:00 UTC',
|
||||
child_info: { //Section child_info
|
||||
children: [{ // Section children
|
||||
graded: true,
|
||||
due_date: 'Jul 10, 2014 at 00:00 UTC',
|
||||
release_date: 'Jul 09, 2014 at 00:00 UTC',
|
||||
start: "2014-07-09T00:00:00Z",
|
||||
format: "Lab",
|
||||
due: "2014-07-10T00:00:00Z"
|
||||
}]
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('can be deleted', function() {
|
||||
var promptSpy = view_helpers.createPromptSpy();
|
||||
createCourseOutlinePage(this, mockCourseJSON);
|
||||
getItemHeaders('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');
|
||||
getItemsOfType('subsection').find('> .outline-content > .add-unit .button-new').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',
|
||||
displayNameWrapper,
|
||||
subsectionModel;
|
||||
createCourseOutlinePage(this, mockCourseJSON);
|
||||
displayNameWrapper = getDisplayNameWrapper();
|
||||
displayNameInput = view_helpers.inlineEdit(displayNameWrapper, 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
|
||||
displayNameWrapper = getItemHeaders('subsection').find('.wrapper-xblock-field');
|
||||
view_helpers.verifyInlineEditChange(displayNameWrapper, 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() {
|
||||
createCourseOutlinePage(this, mockCourseJSON);
|
||||
verifyItemsExpanded('subsection', false);
|
||||
expandItemsAndVerifyState('subsection');
|
||||
collapseItemsAndVerifyState('subsection');
|
||||
expandItemsAndVerifyState('subsection');
|
||||
});
|
||||
|
||||
it('can be edited', function() {
|
||||
createCourseOutlinePage(this, mockCourseJSON, false);
|
||||
outlinePage.$('.outline-subsection .configure-button').click();
|
||||
setEditModalValues("7/9/2014", "7/10/2014", "Lab");
|
||||
$(".edit-outline-item-modal .action-save").click();
|
||||
create_sinon.expectJsonRequest(requests, 'POST', '/xblock/mock-subsection', {
|
||||
"graderType":"Lab",
|
||||
"metadata":{
|
||||
"start":"2014-07-09T00:00:00.000Z",
|
||||
"due":"2014-07-10T00:00:00.000Z"
|
||||
}
|
||||
});
|
||||
expect(requests[0].requestHeaders['X-HTTP-Method-Override']).toBe('PATCH');
|
||||
|
||||
// This is the response for the change operation.
|
||||
create_sinon.respondWithJson(requests, {});
|
||||
create_sinon.expectJsonRequest(requests, 'GET', '/xblock/outline/mock-section')
|
||||
expect(requests.length).toBe(2);
|
||||
// This is the response for the subsequent fetch operation for the section.
|
||||
create_sinon.respondWithJson(requests, mockServerValuesJson);
|
||||
|
||||
expect($(".outline-subsection .status-release-value")).toContainText("Jul 09, 2014 at 00:00 UTC");
|
||||
expect($(".outline-subsection .status-grading-date")).toContainText("Due: Jul 10, 2014 at 00:00 UTC");
|
||||
expect($(".outline-subsection .status-grading-value")).toContainText("Lab");
|
||||
|
||||
expect($(".outline-item .outline-subsection .status-grading-value")).toContainText("Lab");
|
||||
outlinePage.$('.outline-item .outline-subsection .configure-button').click();
|
||||
expect($("#start_date").val()).toBe('7/9/2014');
|
||||
expect($("#due_date").val()).toBe('7/10/2014');
|
||||
expect($("#grading_type").val()).toBe('Lab');
|
||||
});
|
||||
|
||||
it('release date, due date and grading type can be cleared.', function() {
|
||||
createCourseOutlinePage(this, mockCourseJSON, false);
|
||||
outlinePage.$('.outline-item .outline-subsection .configure-button').click();
|
||||
setEditModalValues("7/9/2014", "7/10/2014", "Lab");
|
||||
$(".edit-outline-item-modal .action-save").click();
|
||||
|
||||
// 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, mockServerValuesJson);
|
||||
|
||||
expect($(".outline-subsection .status-release-value")).toContainText("Jul 09, 2014 at 00:00 UTC");
|
||||
expect($(".outline-subsection .status-grading-date")).toContainText("Due: Jul 10, 2014 at 00:00 UTC");
|
||||
expect($(".outline-subsection .status-grading-value")).toContainText("Lab");
|
||||
|
||||
outlinePage.$('.outline-subsection .configure-button').click();
|
||||
expect($("#start_date").val()).toBe('7/9/2014');
|
||||
expect($("#due_date").val()).toBe('7/10/2014');
|
||||
expect($("#grading_type").val()).toBe('Lab');
|
||||
|
||||
$(".edit-outline-item-modal .scheduled-date-input .action-clear").click();
|
||||
$(".edit-outline-item-modal .due-date-input .action-clear").click();
|
||||
expect($("#start_date").val()).toBe('');
|
||||
expect($("#due_date").val()).toBe('');
|
||||
|
||||
$("#grading_type").val('notgraded');
|
||||
|
||||
$(".edit-outline-item-modal .action-save").click();
|
||||
|
||||
// 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,
|
||||
createMockSectionJSON('mock-section', 'Mock Section', [
|
||||
createMockSubsectionJSON('mock-subsection', 'Mock Subsection', [])
|
||||
])
|
||||
);
|
||||
expect($(".outline-subsection .status-release-value")).not.toContainText("Jul 09, 2014 at 00:00 UTC");
|
||||
expect($(".outline-subsection .status-grading-date")).not.toExist();
|
||||
expect($(".outline-subsection .status-grading-value")).not.toExist();
|
||||
});
|
||||
});
|
||||
|
||||
// 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);
|
||||
expandItemsAndVerifyState('subsection');
|
||||
getItemHeaders('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 unitAnchor;
|
||||
createCourseOutlinePage(this, mockCourseJSON);
|
||||
expandItemsAndVerifyState('subsection');
|
||||
unitAnchor = getItemsOfType('unit').find('.unit-title a');
|
||||
expect(unitAnchor.attr('href')).toBe('/container/mock-unit');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Date and Time picker", function() {
|
||||
// Two datetime formats can came from server: '%Y-%m-%dT%H:%M:%SZ' and %Y-%m-%dT%H:%M:%S+TZ:TZ'
|
||||
it('can parse dates in both formats that can come from server', function() {
|
||||
createCourseOutlinePage(this, mockCourseJSON, false);
|
||||
outlinePage.$('.outline-subsection .configure-button').click();
|
||||
expect($("#start_date").val()).toBe('');
|
||||
expect($("#start_time").val()).toBe('');
|
||||
DateUtils.setDate($("#start_date"), ("#start_time"), "2015-08-10T05:10:00Z");
|
||||
expect($("#start_date").val()).toBe('8/10/2015');
|
||||
expect($("#start_time").val()).toBe('05:10');
|
||||
DateUtils.setDate($("#start_date"), ("#start_time"), "2014-07-09T00:00:00+00:00");
|
||||
expect($("#start_date").val()).toBe('7/9/2014');
|
||||
expect($("#start_time").val()).toBe('00:00');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -52,8 +52,8 @@ define([
|
||||
var view = initializePage();
|
||||
expect(view.$('.ui-loading')).toBeVisible();
|
||||
view.render();
|
||||
expect(view.$(itemClassName)).toExist()
|
||||
expect(view.$('.ui-loading')).toBeHidden();
|
||||
expect(view.$(itemClassName)).toExist();
|
||||
expect(view.$('.ui-loading')).toHaveClass('is-hidden');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
118
cms/static/js/spec/views/unit_outline_spec.js
Normal file
118
cms/static/js/spec/views/unit_outline_spec.js
Normal file
@@ -0,0 +1,118 @@
|
||||
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',
|
||||
visibility_state: 'unscheduled',
|
||||
ancestor_info: {
|
||||
ancestors: [{
|
||||
id: 'mock-subsection',
|
||||
category: 'sequential',
|
||||
display_name: 'Mock Subsection',
|
||||
studio_url: '/course/mock-course?show=mock-subsection',
|
||||
visibility_state: 'unscheduled',
|
||||
child_info: {
|
||||
category: 'vertical',
|
||||
display_name: 'Unit',
|
||||
children: [{
|
||||
id: 'mock-unit',
|
||||
category: 'vertical',
|
||||
display_name: displayName,
|
||||
studio_url: '/container/mock-unit',
|
||||
visibility_state: 'unscheduled'
|
||||
}, {
|
||||
id: 'mock-unit-2',
|
||||
category: 'vertical',
|
||||
display_name: 'Mock Unit 2',
|
||||
studio_url: '/container/mock-unit-2',
|
||||
visibility_state: 'unscheduled'
|
||||
}]
|
||||
}
|
||||
}, {
|
||||
id: 'mock-section',
|
||||
category: 'chapter',
|
||||
display_name: 'Section',
|
||||
studio_url: '/course/slashes:mock-course?show=mock-section',
|
||||
visibility_state: 'unscheduled'
|
||||
}, {
|
||||
id: 'mock-course',
|
||||
category: 'course',
|
||||
display_name: 'Mock Course',
|
||||
studio_url: '/course/mock-course',
|
||||
visibility_state: 'unscheduled'
|
||||
}]
|
||||
},
|
||||
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.$('.list-sections')).toExist();
|
||||
expect(unitOutlineView.$('.list-subsections')).toExist();
|
||||
expect(unitOutlineView.$('.list-units')).toExist();
|
||||
});
|
||||
|
||||
it('can add a unit', function() {
|
||||
var redirectSpy;
|
||||
createUnitOutlineView(this, createMockXBlockInfo('Mock Unit'));
|
||||
redirectSpy = spyOn(ViewUtils, 'redirect');
|
||||
unitOutlineView.$('.outline-subsection > .outline-content > .add-unit .button-new').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));
|
||||
expect(unitOutlineView.$('.outline-unit .unit-title').first().text().trim()).toBe(updatedDisplayName);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,272 +0,0 @@
|
||||
define(["jquery", "underscore.string", "jasmine", "coffee/src/views/unit", "js/models/module_info",
|
||||
"js/spec_helpers/create_sinon", "js/spec_helpers/edit_helpers", "jasmine-stealth"],
|
||||
function ($, str, jasmine, UnitEditView, ModuleModel, create_sinon, edit_helpers) {
|
||||
var requests, unitView, initialize, lastRequest, respondWithHtml, verifyComponents, i,
|
||||
mockXBlockEditorHtml = readFixtures('mock/mock-xblock-editor.underscore');
|
||||
|
||||
respondWithHtml = function(html, requestIndex) {
|
||||
create_sinon.respondWithJson(
|
||||
requests,
|
||||
{ html: html, "resources": [] },
|
||||
requestIndex
|
||||
);
|
||||
};
|
||||
|
||||
initialize = function(test) {
|
||||
var mockXBlockHtml = readFixtures('mock/mock-unit-page-xblock.underscore'),
|
||||
mockChildContainerHtml = readFixtures('mock/mock-unit-page-child-container.underscore'),
|
||||
model;
|
||||
requests = create_sinon.requests(test);
|
||||
model = new ModuleModel({
|
||||
id: 'unit_locator',
|
||||
state: 'draft'
|
||||
});
|
||||
unitView = new UnitEditView({
|
||||
el: $('.main-wrapper'),
|
||||
templates: edit_helpers.mockComponentTemplates,
|
||||
model: model
|
||||
});
|
||||
|
||||
// Respond with renderings for the two xblocks in the unit (the second is itself a child container)
|
||||
respondWithHtml(mockXBlockHtml, 0);
|
||||
respondWithHtml(mockChildContainerHtml, 1);
|
||||
};
|
||||
|
||||
lastRequest = function() { return requests[requests.length - 1]; };
|
||||
|
||||
verifyComponents = function (unit, locators) {
|
||||
var components = unit.$(".component");
|
||||
expect(components.length).toBe(locators.length);
|
||||
for (i = 0; i < locators.length; i++) {
|
||||
expect($(components[i]).data('locator')).toBe(locators[i]);
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(function() {
|
||||
edit_helpers.installMockXBlock();
|
||||
|
||||
// needed to stub out the ajax
|
||||
window.analytics = jasmine.createSpyObj('analytics', ['track']);
|
||||
window.course_location_analytics = jasmine.createSpy('course_location_analytics');
|
||||
window.unit_location_analytics = jasmine.createSpy('unit_location_analytics');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
edit_helpers.uninstallMockXBlock();
|
||||
});
|
||||
|
||||
describe("UnitEditView", function() {
|
||||
beforeEach(function() {
|
||||
edit_helpers.installEditTemplates();
|
||||
appendSetFixtures(readFixtures('mock/mock-unit-page.underscore'));
|
||||
});
|
||||
|
||||
describe('duplicateComponent', function() {
|
||||
var clickDuplicate;
|
||||
|
||||
clickDuplicate = function (index) {
|
||||
unitView.$(".duplicate-button")[index].click();
|
||||
};
|
||||
|
||||
it('sends the correct JSON to the server', function () {
|
||||
initialize(this);
|
||||
clickDuplicate(0);
|
||||
edit_helpers.verifyXBlockRequest(requests, {
|
||||
"duplicate_source_locator": "loc_1",
|
||||
"parent_locator": "unit_locator"
|
||||
});
|
||||
});
|
||||
|
||||
it('inserts duplicated component immediately after source upon success', function () {
|
||||
initialize(this);
|
||||
clickDuplicate(0);
|
||||
create_sinon.respondWithJson(requests, {"locator": "duplicated_item"});
|
||||
verifyComponents(unitView, ['loc_1', 'duplicated_item', 'loc_2']);
|
||||
});
|
||||
|
||||
it('inserts duplicated component at end if source at end', function () {
|
||||
initialize(this);
|
||||
clickDuplicate(1);
|
||||
create_sinon.respondWithJson(requests, {"locator": "duplicated_item"});
|
||||
verifyComponents(unitView, ['loc_1', 'loc_2', 'duplicated_item']);
|
||||
});
|
||||
|
||||
it('shows a notification while duplicating', function () {
|
||||
var notificationSpy = edit_helpers.createNotificationSpy();
|
||||
initialize(this);
|
||||
clickDuplicate(0);
|
||||
edit_helpers.verifyNotificationShowing(notificationSpy, /Duplicating/);
|
||||
create_sinon.respondWithJson(requests, {"locator": "new_item"});
|
||||
edit_helpers.verifyNotificationHidden(notificationSpy);
|
||||
});
|
||||
|
||||
it('does not insert duplicated component upon failure', function () {
|
||||
initialize(this);
|
||||
clickDuplicate(0);
|
||||
create_sinon.respondWithError(requests);
|
||||
verifyComponents(unitView, ['loc_1', 'loc_2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNewComponent ', function () {
|
||||
var clickNewComponent;
|
||||
|
||||
clickNewComponent = function () {
|
||||
unitView.$(".new-component .new-component-type a.single-template").click();
|
||||
};
|
||||
|
||||
it('sends the correct JSON to the server', function () {
|
||||
initialize(this);
|
||||
clickNewComponent();
|
||||
edit_helpers.verifyXBlockRequest(requests, {
|
||||
"category": "discussion",
|
||||
"type": "discussion",
|
||||
"parent_locator": "unit_locator"
|
||||
});
|
||||
});
|
||||
|
||||
it('inserts new component at end', function () {
|
||||
initialize(this);
|
||||
clickNewComponent();
|
||||
create_sinon.respondWithJson(requests, {"locator": "new_item"});
|
||||
verifyComponents(unitView, ['loc_1', 'loc_2', 'new_item']);
|
||||
});
|
||||
|
||||
it('shows a notification while creating', function () {
|
||||
var notificationSpy = edit_helpers.createNotificationSpy();
|
||||
initialize(this);
|
||||
clickNewComponent();
|
||||
edit_helpers.verifyNotificationShowing(notificationSpy, /Adding/);
|
||||
create_sinon.respondWithJson(requests, {"locator": "new_item"});
|
||||
edit_helpers.verifyNotificationHidden(notificationSpy);
|
||||
});
|
||||
|
||||
it('does not insert new component upon failure', function () {
|
||||
initialize(this);
|
||||
clickNewComponent();
|
||||
create_sinon.respondWithError(requests);
|
||||
verifyComponents(unitView, ['loc_1', 'loc_2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Disabled edit/publish links during ajax call", function() {
|
||||
var link,
|
||||
draft_states = [
|
||||
{
|
||||
state: "draft",
|
||||
selector: ".publish-draft"
|
||||
},
|
||||
{
|
||||
state: "public",
|
||||
selector: ".create-draft"
|
||||
}
|
||||
];
|
||||
|
||||
function test_link_disabled_during_ajax_call(draft_state) {
|
||||
it("re-enables the " + draft_state.selector + " link once the ajax call returns", function() {
|
||||
initialize(this);
|
||||
link = $(draft_state.selector);
|
||||
expect(link).not.toHaveClass('is-disabled');
|
||||
link.click();
|
||||
expect(link).toHaveClass('is-disabled');
|
||||
create_sinon.respondWithError(requests);
|
||||
expect(link).not.toHaveClass('is-disabled');
|
||||
});
|
||||
}
|
||||
|
||||
for (i = 0; i < draft_states.length; i++) {
|
||||
test_link_disabled_during_ajax_call(draft_states[i]);
|
||||
}
|
||||
});
|
||||
|
||||
describe("Editing an xblock", function() {
|
||||
var newDisplayName = 'New Display Name';
|
||||
|
||||
beforeEach(function () {
|
||||
edit_helpers.installMockXBlock({
|
||||
data: "<p>Some HTML</p>",
|
||||
metadata: {
|
||||
display_name: newDisplayName
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
edit_helpers.uninstallMockXBlock();
|
||||
edit_helpers.cancelModalIfShowing();
|
||||
});
|
||||
|
||||
it('can show an edit modal for a child xblock', function() {
|
||||
var editButtons;
|
||||
initialize(this);
|
||||
editButtons = unitView.$('.edit-button');
|
||||
// The container renders two mock xblocks
|
||||
expect(editButtons.length).toBe(2);
|
||||
editButtons[1].click();
|
||||
// Make sure that the correct xblock is requested to be edited
|
||||
expect(str.startsWith(lastRequest().url, '/xblock/loc_2/studio_view')).toBeTruthy();
|
||||
create_sinon.respondWithJson(requests, {
|
||||
html: mockXBlockEditorHtml,
|
||||
resources: []
|
||||
});
|
||||
|
||||
// Expect that a modal is shown with the correct title
|
||||
expect(edit_helpers.isShowingModal()).toBeTruthy();
|
||||
expect(edit_helpers.getModalTitle()).toBe('Editing: Test Child Container');
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
describe("Editing an xmodule", function() {
|
||||
var mockXModuleEditor = readFixtures('mock/mock-xmodule-editor.underscore'),
|
||||
newDisplayName = 'New Display Name';
|
||||
|
||||
beforeEach(function () {
|
||||
edit_helpers.installMockXModule({
|
||||
data: "<p>Some HTML</p>",
|
||||
metadata: {
|
||||
display_name: newDisplayName
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
edit_helpers.uninstallMockXModule();
|
||||
edit_helpers.cancelModalIfShowing();
|
||||
});
|
||||
|
||||
it('can save changes to settings', function() {
|
||||
var editButtons, modal, mockUpdatedXBlockHtml;
|
||||
mockUpdatedXBlockHtml = readFixtures('mock/mock-updated-xblock.underscore');
|
||||
initialize(this);
|
||||
editButtons = unitView.$('.edit-button');
|
||||
// The container renders two mock xblocks
|
||||
expect(editButtons.length).toBe(2);
|
||||
editButtons[1].click();
|
||||
create_sinon.respondWithJson(requests, {
|
||||
html: mockXModuleEditor,
|
||||
resources: []
|
||||
});
|
||||
|
||||
modal = $('.edit-xblock-modal');
|
||||
// Click on the settings tab
|
||||
modal.find('.settings-button').click();
|
||||
// Change the display name's text
|
||||
modal.find('.setting-input').text("Mock Update");
|
||||
// Press the save button
|
||||
modal.find('.action-save').click();
|
||||
// Respond to the save
|
||||
create_sinon.respondWithJson(requests, {
|
||||
id: 'mock-id'
|
||||
});
|
||||
|
||||
// Respond to the request to refresh
|
||||
respondWithHtml(mockUpdatedXBlockHtml);
|
||||
|
||||
// Verify that the xblock was updated
|
||||
expect(unitView.$('.mock-updated-content').text()).toBe('Mock Update');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -85,7 +85,7 @@ define([ "jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helper
|
||||
});
|
||||
// Give the mock xblock a save method...
|
||||
editor.xblock.save = window.MockDescriptor.save;
|
||||
editor.save();
|
||||
editor.model.save(editor.getXModuleData());
|
||||
request = requests[requests.length - 1];
|
||||
response = JSON.parse(request.requestBody);
|
||||
expect(response.metadata.display_name).toBe(testDisplayName);
|
||||
|
||||
155
cms/static/js/spec/views/xblock_string_field_editor_spec.js
Normal file
155
cms/static/js/spec/views/xblock_string_field_editor_spec.js
Normal file
@@ -0,0 +1,155 @@
|
||||
define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers", "js/spec_helpers/edit_helpers", "js/models/xblock_info", "js/views/xblock_string_field_editor"],
|
||||
function ($, create_sinon, view_helpers, edit_helpers, XBlockInfo, XBlockStringFieldEditor) {
|
||||
describe("XBlockStringFieldEditorView", function () {
|
||||
var initialDisplayName, updatedDisplayName, getXBlockInfo, getFieldEditorView;
|
||||
|
||||
getXBlockInfo = function (displayName) {
|
||||
return new XBlockInfo(
|
||||
{
|
||||
display_name: displayName,
|
||||
id: "my_xblock"
|
||||
},
|
||||
{ parse: true }
|
||||
);
|
||||
};
|
||||
|
||||
getFieldEditorView = function (xblockInfo) {
|
||||
if (xblockInfo === undefined) {
|
||||
xblockInfo = getXBlockInfo(initialDisplayName);
|
||||
}
|
||||
return new XBlockStringFieldEditor({
|
||||
model: xblockInfo,
|
||||
el: $('.wrapper-xblock-field')
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(function () {
|
||||
initialDisplayName = "Default Display Name";
|
||||
updatedDisplayName = "Updated Display Name";
|
||||
view_helpers.installTemplate('xblock-string-field-editor');
|
||||
appendSetFixtures(
|
||||
'<div class="wrapper-xblock-field incontext-editor is-editable"' +
|
||||
'data-field="display_name" data-field-display-name="Display Name">' +
|
||||
'<h1 class="page-header-title xblock-field-value incontext-editor-value"><span class="title-value">' + initialDisplayName + '</span></h1>' +
|
||||
'</div>'
|
||||
);
|
||||
});
|
||||
|
||||
describe('Editing', function () {
|
||||
var expectPostedNewDisplayName, expectEditCanceled;
|
||||
|
||||
expectPostedNewDisplayName = function (requests, displayName) {
|
||||
create_sinon.expectJsonRequest(requests, 'POST', '/xblock/my_xblock', {
|
||||
metadata: {
|
||||
display_name: displayName
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
expectEditCanceled = function (test, fieldEditorView, options) {
|
||||
var requests, initialRequests, displayNameInput;
|
||||
requests = create_sinon.requests(test);
|
||||
initialRequests = requests.length;
|
||||
displayNameInput = edit_helpers.inlineEdit(fieldEditorView.$el, options.newTitle);
|
||||
if (options.pressEscape) {
|
||||
displayNameInput.simulate("keydown", { keyCode: $.simulate.keyCode.ESCAPE });
|
||||
displayNameInput.simulate("keyup", { keyCode: $.simulate.keyCode.ESCAPE });
|
||||
} else if (options.clickCancel) {
|
||||
fieldEditorView.$('button[name=cancel]').click();
|
||||
} else {
|
||||
displayNameInput.change();
|
||||
}
|
||||
// No requests should be made when the edit is cancelled client-side
|
||||
expect(initialRequests).toBe(requests.length);
|
||||
edit_helpers.verifyInlineEditChange(fieldEditorView.$el, initialDisplayName);
|
||||
expect(fieldEditorView.model.get('display_name')).toBe(initialDisplayName);
|
||||
};
|
||||
|
||||
it('can inline edit the display name', function () {
|
||||
var requests, fieldEditorView;
|
||||
requests = create_sinon.requests(this);
|
||||
fieldEditorView = getFieldEditorView().render();
|
||||
edit_helpers.inlineEdit(fieldEditorView.$el, updatedDisplayName);
|
||||
fieldEditorView.$('button[name=submit]').click();
|
||||
expectPostedNewDisplayName(requests, updatedDisplayName);
|
||||
// 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});
|
||||
edit_helpers.verifyInlineEditChange(fieldEditorView.$el, updatedDisplayName);
|
||||
});
|
||||
|
||||
it('does not change the title when a display name update fails', function () {
|
||||
var requests, fieldEditorView, initialRequests;
|
||||
requests = create_sinon.requests(this);
|
||||
initialRequests = requests.length;
|
||||
fieldEditorView = getFieldEditorView().render();
|
||||
edit_helpers.inlineEdit(fieldEditorView.$el, updatedDisplayName);
|
||||
fieldEditorView.$('button[name=submit]').click();
|
||||
expectPostedNewDisplayName(requests, updatedDisplayName);
|
||||
create_sinon.respondWithError(requests);
|
||||
// No fetch operation should occur.
|
||||
expect(initialRequests + 1).toBe(requests.length);
|
||||
edit_helpers.verifyInlineEditChange(fieldEditorView.$el, initialDisplayName, updatedDisplayName);
|
||||
});
|
||||
|
||||
it('trims whitespace from the display name', function () {
|
||||
var requests, fieldEditorView;
|
||||
requests = create_sinon.requests(this);
|
||||
fieldEditorView = getFieldEditorView().render();
|
||||
updatedDisplayName += ' ';
|
||||
edit_helpers.inlineEdit(fieldEditorView.$el, updatedDisplayName);
|
||||
fieldEditorView.$('button[name=submit]').click();
|
||||
expectPostedNewDisplayName(requests, updatedDisplayName.trim());
|
||||
// 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.trim()});
|
||||
edit_helpers.verifyInlineEditChange(fieldEditorView.$el, updatedDisplayName.trim());
|
||||
});
|
||||
|
||||
it('does not change the title when input is the empty string', function () {
|
||||
var fieldEditorView = getFieldEditorView().render();
|
||||
expectEditCanceled(this, fieldEditorView, {newTitle: ''});
|
||||
});
|
||||
|
||||
it('does not change the title when input is whitespace-only', function () {
|
||||
var fieldEditorView = getFieldEditorView().render();
|
||||
expectEditCanceled(this, fieldEditorView, {newTitle: ' '});
|
||||
});
|
||||
|
||||
it('can cancel an inline edit by pressing escape', function () {
|
||||
var fieldEditorView = getFieldEditorView().render();
|
||||
expectEditCanceled(this, fieldEditorView, {newTitle: updatedDisplayName, pressEscape: true});
|
||||
});
|
||||
|
||||
it('can cancel an inline edit by clicking cancel', function () {
|
||||
var fieldEditorView = getFieldEditorView().render();
|
||||
expectEditCanceled(this, fieldEditorView, {newTitle: updatedDisplayName, clickCancel: true});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rendering', function () {
|
||||
var expectInputMatchesModelDisplayName = function (displayName) {
|
||||
var fieldEditorView = getFieldEditorView(getXBlockInfo(displayName)).render();
|
||||
expect(fieldEditorView.$('.xblock-field-input').val()).toBe(displayName);
|
||||
};
|
||||
|
||||
it('renders single quotes in input field', function () {
|
||||
expectInputMatchesModelDisplayName('Updated \'Display Name\'');
|
||||
});
|
||||
|
||||
it('renders double quotes in input field', function () {
|
||||
expectInputMatchesModelDisplayName('Updated "Display Name"');
|
||||
});
|
||||
|
||||
it('renders open angle bracket in input field', function () {
|
||||
expectInputMatchesModelDisplayName(updatedDisplayName + '<');
|
||||
});
|
||||
|
||||
it('renders close angle bracket in input field', function () {
|
||||
expectInputMatchesModelDisplayName('>' + updatedDisplayName);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -83,8 +83,8 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers
|
||||
|
||||
// Add templates needed by the settings editor
|
||||
modal_helpers.installTemplate('metadata-editor');
|
||||
modal_helpers.installTemplate('metadata-number-entry');
|
||||
modal_helpers.installTemplate('metadata-string-entry');
|
||||
modal_helpers.installTemplate('metadata-number-entry', false, 'metadata-number-entry');
|
||||
modal_helpers.installTemplate('metadata-string-entry', false, 'metadata-string-entry');
|
||||
};
|
||||
|
||||
showEditModal = function(requests, xblockElement, model, mockHtml, options) {
|
||||
|
||||
@@ -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,17 +1,18 @@
|
||||
/**
|
||||
* 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) {
|
||||
var template = readFixtures(templateName + '.underscore'),
|
||||
installTemplate = function(templateName, isFirst, templateId) {
|
||||
var template = readFixtures(templateName + '.underscore');
|
||||
if (!templateId) {
|
||||
templateId = templateName + '-tpl';
|
||||
}
|
||||
|
||||
if (isFirst) {
|
||||
setFixtures($('<script>', { id: templateId, type: 'text/template' }).text(template));
|
||||
@@ -68,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) {
|
||||
@@ -89,6 +90,35 @@ 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(editorWrapper, newValue) {
|
||||
var inputField = editorWrapper.find('.xblock-field-input'),
|
||||
editButton = editorWrapper.find('.xblock-field-value-edit');
|
||||
editButton.click();
|
||||
expect(editorWrapper).toHaveClass('is-editing');
|
||||
inputField.val(newValue);
|
||||
return inputField;
|
||||
};
|
||||
|
||||
verifyInlineEditChange = function(editorWrapper, expectedValue, failedValue) {
|
||||
var displayName = editorWrapper.find('.xblock-field-value');
|
||||
expect(displayName.text()).toBe(expectedValue);
|
||||
if (failedValue) {
|
||||
expect(editorWrapper).toHaveClass('is-editing');
|
||||
} else {
|
||||
expect(editorWrapper).not.toHaveClass('is-editing');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
'installTemplate': installTemplate,
|
||||
@@ -100,6 +130,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,4 +1,4 @@
|
||||
define(["jquery", "jquery.ui", "jquery.timepicker"], function($) {
|
||||
define(["jquery", "date", "jquery.ui", "jquery.timepicker"], function($, date) {
|
||||
var getDate = function (datepickerInput, timepickerInput) {
|
||||
// given a pair of inputs (datepicker and timepicker), return a JS Date
|
||||
// object that corresponds to the datetime.js that they represent. Assume
|
||||
@@ -14,5 +14,19 @@ define(["jquery", "jquery.ui", "jquery.timepicker"], function($) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return getDate;
|
||||
|
||||
var setDate = function (datepickerInput, timepickerInput, datetime) {
|
||||
// given a pair of inputs (datepicker and timepicker) and the date as an
|
||||
// ISO-formatted date string.
|
||||
datetime = date.parse(datetime);
|
||||
if (datetime) {
|
||||
$(datepickerInput).datepicker("setDate", datetime);
|
||||
$(timepickerInput).timepicker("setTime", datetime);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
getDate: getDate,
|
||||
setDate: setDate
|
||||
};
|
||||
});
|
||||
@@ -6,6 +6,7 @@ define(["jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notif
|
||||
droppableClasses: 'drop-target drop-target-prepend drop-target-before drop-target-after',
|
||||
validDropClass: "valid-drop",
|
||||
expandOnDropClass: "expand-on-drop",
|
||||
collapsedClass: "is-collapsed",
|
||||
|
||||
/*
|
||||
* Determine information about where to drop the currently dragged
|
||||
@@ -14,7 +15,7 @@ define(["jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notif
|
||||
*/
|
||||
findDestination: function (ele, yChange) {
|
||||
var eleY = ele.offset().top;
|
||||
var eleYEnd = eleY + ele.height();
|
||||
var eleYEnd = eleY + ele.outerHeight();
|
||||
var containers = $(ele.data('droppable-class'));
|
||||
|
||||
for (var i = 0; i < containers.length; i++) {
|
||||
@@ -28,7 +29,7 @@ define(["jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notif
|
||||
// element is on top of its parent list -- don't check the
|
||||
// position of the container
|
||||
var parentList = container.parents(ele.data('parent-location-selector')).first();
|
||||
if (parentList.hasClass('collapsed')) {
|
||||
if (parentList.hasClass(this.collapsedClass)) {
|
||||
var parentListTop = parentList.offset().top;
|
||||
// To make it easier to drop subsections into collapsed sections (which have
|
||||
// a lot of visual padding around them), allow a fudge factor around the
|
||||
@@ -36,7 +37,7 @@ define(["jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notif
|
||||
var collapseFudge = 10;
|
||||
if (Math.abs(eleY - parentListTop) < collapseFudge ||
|
||||
(eleY > parentListTop &&
|
||||
eleYEnd - collapseFudge <= parentListTop + parentList.height())
|
||||
eleYEnd - collapseFudge <= parentListTop + parentList.outerHeight())
|
||||
) {
|
||||
return {
|
||||
ele: container,
|
||||
@@ -65,12 +66,12 @@ define(["jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notif
|
||||
for (var j = 0; j < siblings.length; j++) {
|
||||
var $sibling = $(siblings[j]);
|
||||
var siblingY = $sibling.offset().top;
|
||||
var siblingHeight = $sibling.height();
|
||||
var siblingHeight = $sibling.outerHeight();
|
||||
var siblingYEnd = siblingY + siblingHeight;
|
||||
|
||||
// Facilitate dropping into the beginning or end of a list
|
||||
// (coming from opposite direction) via a "fudge factor". Math.min is for Jasmine test.
|
||||
var fudge = Math.min(Math.ceil(siblingHeight / 2), 20);
|
||||
var fudge = Math.min(Math.ceil(siblingHeight / 2), 35);
|
||||
|
||||
// Dragging to top or bottom of a list with only one element is tricky
|
||||
// because the element being dragged may be the same size as the sibling.
|
||||
@@ -158,12 +159,16 @@ define(["jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notif
|
||||
// The direction the drag is moving in (negative means up, positive down).
|
||||
dragDirection: 0
|
||||
};
|
||||
if (!ele.hasClass('collapsed')) {
|
||||
ele.addClass('collapsed');
|
||||
if (!ele.hasClass(this.collapsedClass)) {
|
||||
ele.addClass(this.collapsedClass);
|
||||
ele.find('.expand-collapse').first().addClass('expand').removeClass('collapse');
|
||||
// onDragStart gets called again after the collapse, so we can't just store a variable in the dragState.
|
||||
ele.addClass(this.expandOnDropClass);
|
||||
}
|
||||
|
||||
// We should remove this class name before start dragging to
|
||||
// avoid performance issues.
|
||||
ele.removeClass('was-dragging');
|
||||
},
|
||||
|
||||
onDragMove: function (draggie, event, pointer) {
|
||||
@@ -251,44 +256,57 @@ define(["jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notif
|
||||
},
|
||||
|
||||
pointerInBounds: function (pointer, ele) {
|
||||
return pointer.clientX >= ele.offset().left && pointer.clientX < ele.offset().left + ele.width();
|
||||
return pointer.clientX >= ele.offset().left && pointer.clientX < ele.offset().left + ele.outerWidth();
|
||||
},
|
||||
|
||||
expandElement: function (ele) {
|
||||
ele.removeClass('collapsed');
|
||||
ele.removeClass(this.collapsedClass);
|
||||
ele.find('.expand-collapse').first().removeClass('expand').addClass('collapse');
|
||||
},
|
||||
|
||||
/*
|
||||
* Find all parent-child changes and save them.
|
||||
*/
|
||||
handleReorder: function (ele) {
|
||||
var parentSelector = ele.data('parent-location-selector');
|
||||
var childrenSelector = ele.data('child-selector');
|
||||
var newParentEle = ele.parents(parentSelector).first();
|
||||
var newParentLocator = newParentEle.data('locator');
|
||||
var oldParentLocator = ele.data('parent');
|
||||
handleReorder: function (element) {
|
||||
var parentSelector = element.data('parent-location-selector'),
|
||||
childrenSelector = element.data('child-selector'),
|
||||
newParentEle = element.parents(parentSelector).first(),
|
||||
newParentLocator = newParentEle.data('locator'),
|
||||
oldParentLocator = element.data('parent'),
|
||||
oldParentEle, saving, refreshParent;
|
||||
|
||||
refreshParent = function (element) {
|
||||
var refresh = element.data('refresh');
|
||||
// If drop was into a collapsed parent, the parent will have been
|
||||
// expanded. Views using this class may need to track the
|
||||
// collapse/expand state, so send it with the refresh callback.
|
||||
var collapsed = element.hasClass(this.collapsedClass);
|
||||
if (_.isFunction(refresh)) { refresh(collapsed); }
|
||||
|
||||
};
|
||||
// If the parent has changed, update the children of the old parent.
|
||||
if (newParentLocator !== oldParentLocator) {
|
||||
// Find the old parent element.
|
||||
var oldParentEle = $(parentSelector).filter(function () {
|
||||
oldParentEle = $(parentSelector).filter(function () {
|
||||
return $(this).data('locator') === oldParentLocator;
|
||||
});
|
||||
this.saveItem(oldParentEle, childrenSelector, function () {
|
||||
ele.data('parent', newParentLocator);
|
||||
element.data('parent', newParentLocator);
|
||||
refreshParent(oldParentEle);
|
||||
});
|
||||
}
|
||||
var saving = new NotificationView.Mini({
|
||||
saving = new NotificationView.Mini({
|
||||
title: gettext('Saving…')
|
||||
});
|
||||
saving.show();
|
||||
ele.addClass('was-dropped');
|
||||
element.addClass('was-dropped');
|
||||
// Timeout interval has to match what is in the CSS.
|
||||
setTimeout(function () {
|
||||
ele.removeClass('was-dropped');
|
||||
element.removeClass('was-dropped');
|
||||
}, 1000);
|
||||
this.saveItem(newParentEle, childrenSelector, function () {
|
||||
saving.hide();
|
||||
refreshParent(newParentEle);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -318,27 +336,43 @@ define(["jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notif
|
||||
},
|
||||
|
||||
/*
|
||||
* Make `type` draggable using `handleClass`, able to be dropped
|
||||
* into `droppableClass`, and with parent type
|
||||
* `parentLocationSelector`.
|
||||
* Make DOM element with class `type` draggable using `handleClass`, able to be dropped
|
||||
* into `droppableClass`, and with parent type `parentLocationSelector`.
|
||||
* @param {DOM element, jQuery element} element
|
||||
* @param {Object} options The list of options. Possible options:
|
||||
* `type` - class name of the element.
|
||||
* `handleClass` - specifies on what element the drag interaction starts.
|
||||
* `droppableClass` - specifies on what elements draggable element can be dropped.
|
||||
* `parentLocationSelector` - class name of a parent element with data-locator.
|
||||
* `refresh` - method that will be called after dragging to refresh
|
||||
* views of the target and source xblocks.
|
||||
*/
|
||||
makeDraggable: function (type, handleClass, droppableClass, parentLocationSelector) {
|
||||
_.each(
|
||||
$(type),
|
||||
function (ele) {
|
||||
// Remember data necessary to reconstruct the parent-child relationships
|
||||
$(ele).data('droppable-class', droppableClass);
|
||||
$(ele).data('parent-location-selector', parentLocationSelector);
|
||||
$(ele).data('child-selector', type);
|
||||
var draggable = new Draggabilly(ele, {
|
||||
handle: handleClass,
|
||||
containment: '.wrapper-dnd'
|
||||
});
|
||||
draggable.on('dragStart', _.bind(contentDragger.onDragStart, contentDragger));
|
||||
draggable.on('dragMove', _.bind(contentDragger.onDragMove, contentDragger));
|
||||
draggable.on('dragEnd', _.bind(contentDragger.onDragEnd, contentDragger));
|
||||
}
|
||||
);
|
||||
makeDraggable: function (element, options) {
|
||||
var draggable;
|
||||
options = _.defaults({
|
||||
type: null,
|
||||
handleClass: null,
|
||||
droppableClass: null,
|
||||
parentLocationSelector: null,
|
||||
refresh: null
|
||||
}, options);
|
||||
|
||||
if ($(element).data('droppable-class') !== options.droppableClass) {
|
||||
$(element).data({
|
||||
'droppable-class': options.droppableClass,
|
||||
'parent-location-selector': options.parentLocationSelector,
|
||||
'child-selector': options.type,
|
||||
'refresh': options.refresh
|
||||
});
|
||||
|
||||
draggable = new Draggabilly(element, {
|
||||
handle: options.handleClass,
|
||||
containment: '.wrapper-dnd'
|
||||
});
|
||||
draggable.on('dragStart', _.bind(contentDragger.onDragStart, contentDragger));
|
||||
draggable.on('dragMove', _.bind(contentDragger.onDragMove, contentDragger));
|
||||
draggable.on('dragEnd', _.bind(contentDragger.onDragEnd, contentDragger));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -16,6 +16,13 @@ define(["jquery", "underscore", "backbone", "gettext", "js/utils/handle_iframe_b
|
||||
"click .ui-toggle-expansion": "toggleExpandCollapse"
|
||||
},
|
||||
|
||||
options: {
|
||||
// UX is moving towards using 'is-collapsed' in preference over 'collapsed',
|
||||
// but use the old scheme as the default so that existing code doesn't need
|
||||
// to be rewritten.
|
||||
collapsedClass: 'collapsed'
|
||||
},
|
||||
|
||||
//override the constructor function
|
||||
constructor: function(options) {
|
||||
_.bindAll(this, 'beforeRender', 'render', 'afterRender');
|
||||
@@ -48,75 +55,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, this.options.collapsedClass);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -126,37 +65,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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -20,6 +20,12 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
|
||||
reorderableContainer.sortable({
|
||||
handle: '.drag-handle',
|
||||
|
||||
start: function (event, ui) {
|
||||
// Necessary because of an open bug in JQuery sortable.
|
||||
// http://bugs.jqueryui.com/ticket/4990
|
||||
reorderableContainer.sortable('refreshPositions');
|
||||
},
|
||||
|
||||
stop: function (event, ui) {
|
||||
var saving, hideSaving, removeFromParent;
|
||||
|
||||
@@ -82,7 +88,7 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
|
||||
},
|
||||
|
||||
updateChildren: function (targetParent, successCallback) {
|
||||
var children, childLocators;
|
||||
var children, childLocators, xblockInfo=this.model;
|
||||
|
||||
// Find descendants with class "studio-xblock-wrapper" whose parent === targetParent.
|
||||
// This is necessary to filter our grandchildren, great-grandchildren, etc.
|
||||
@@ -110,6 +116,8 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext",
|
||||
if (successCallback) {
|
||||
successCallback();
|
||||
}
|
||||
// Update publish and last modified information from the server.
|
||||
xblockInfo.fetch();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
195
cms/static/js/views/course_outline.js
Normal file
195
cms/static/js/views/course_outline.js
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 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", "js/views/modals/edit_outline_item", "js/utils/drag_and_drop"],
|
||||
function($, _, XBlockOutlineView, ViewUtils, XBlockOutlineInfo, EditSectionXBlockModal, ContentDragger) {
|
||||
|
||||
var CourseOutlineView = XBlockOutlineView.extend({
|
||||
// takes XBlockOutlineInfo as a model
|
||||
|
||||
templateName: 'course-outline',
|
||||
|
||||
render: function() {
|
||||
var renderResult = XBlockOutlineView.prototype.render.call(this);
|
||||
this.makeContentDraggable(this.el);
|
||||
return renderResult;
|
||||
},
|
||||
|
||||
shouldExpandChildren: function() {
|
||||
return this.expandedLocators.contains(this.model.get('id'));
|
||||
},
|
||||
|
||||
shouldRenderChildren: function() {
|
||||
// Render all nodes up to verticals but not below
|
||||
return !this.model.isVertical();
|
||||
},
|
||||
|
||||
createChildView: function(xblockInfo, parentInfo, parentView) {
|
||||
return new CourseOutlineView({
|
||||
model: xblockInfo,
|
||||
parentInfo: parentInfo,
|
||||
initialState: this.initialState,
|
||||
expandedLocators: this.expandedLocators,
|
||||
template: this.template,
|
||||
parentView: parentView || this
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 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.isChapter() || !view.parentView) {
|
||||
return view;
|
||||
}
|
||||
return getViewToRefresh(view.parentView);
|
||||
};
|
||||
|
||||
view = getViewToRefresh(this);
|
||||
viewState = viewState || {};
|
||||
view.initialState = viewState;
|
||||
return view.model.fetch({});
|
||||
},
|
||||
|
||||
/**
|
||||
* Updates the collapse/expand state for this outline element, and then calls refresh.
|
||||
* @param isCollapsed true if the element should be collapsed, else false
|
||||
*/
|
||||
refreshWithCollapsedState: function(isCollapsed) {
|
||||
var locator = this.model.get('id');
|
||||
if (isCollapsed) {
|
||||
this.expandedLocators.remove(locator);
|
||||
}
|
||||
else {
|
||||
this.expandedLocators.add(locator);
|
||||
}
|
||||
this.refresh();
|
||||
},
|
||||
|
||||
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.expandedLocators = self.expandedLocators;
|
||||
sectionView.render();
|
||||
self.addChildView(sectionView);
|
||||
sectionView.setViewState(initialState);
|
||||
});
|
||||
} else {
|
||||
this.refresh(initialState);
|
||||
}
|
||||
},
|
||||
|
||||
onChildDeleted: function(childView) {
|
||||
var xblockInfo = this.model,
|
||||
children = xblockInfo.get('child_info') && xblockInfo.get('child_info').children;
|
||||
// If deleting a section that isn't the final one, just remove it for efficiency
|
||||
// as it cannot visually effect the other sections.
|
||||
if (childView.model.isChapter() && children && children.length > 1) {
|
||||
childView.$el.remove();
|
||||
children.splice(children.indexOf(childView.model), 1);
|
||||
} else {
|
||||
this.refresh();
|
||||
}
|
||||
},
|
||||
|
||||
createNewItemViewState: function(locator, scrollOffset) {
|
||||
this.expandedLocators.add(locator);
|
||||
return {
|
||||
locator_to_show: locator,
|
||||
edit_display_name: true,
|
||||
scroll_offset: scrollOffset || 0
|
||||
};
|
||||
},
|
||||
|
||||
editXBlock: function() {
|
||||
var modal = new EditSectionXBlockModal({
|
||||
model: this.model,
|
||||
onSave: this.refresh.bind(this)
|
||||
});
|
||||
|
||||
modal.show();
|
||||
},
|
||||
|
||||
addButtonActions: function(element) {
|
||||
XBlockOutlineView.prototype.addButtonActions.apply(this, arguments);
|
||||
element.find('.configure-button').click(function(event) {
|
||||
event.preventDefault();
|
||||
this.editXBlock();
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
makeContentDraggable: function(element) {
|
||||
if ($(element).hasClass("outline-section")) {
|
||||
ContentDragger.makeDraggable(element, {
|
||||
type: '.outline-section',
|
||||
handleClass: '.section-drag-handle',
|
||||
droppableClass: 'ol.list-sections',
|
||||
parentLocationSelector: 'article.outline',
|
||||
refresh: this.refreshWithCollapsedState.bind(this)
|
||||
});
|
||||
}
|
||||
else if ($(element).hasClass("outline-subsection")) {
|
||||
ContentDragger.makeDraggable(element, {
|
||||
type: '.outline-subsection',
|
||||
handleClass: '.subsection-drag-handle',
|
||||
droppableClass: 'ol.list-subsections',
|
||||
parentLocationSelector: 'li.outline-section',
|
||||
refresh: this.refreshWithCollapsedState.bind(this)
|
||||
});
|
||||
}
|
||||
else if ($(element).hasClass("outline-unit")) {
|
||||
ContentDragger.makeDraggable(element, {
|
||||
type: '.outline-unit',
|
||||
handleClass: '.unit-drag-handle',
|
||||
droppableClass: 'ol.list-units',
|
||||
parentLocationSelector: 'li.outline-subsection',
|
||||
refresh: this.refreshWithCollapsedState.bind(this)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return CourseOutlineView;
|
||||
}); // end define();
|
||||
@@ -1,8 +1,8 @@
|
||||
define([
|
||||
'js/views/baseview', 'underscore', 'jquery', 'gettext',
|
||||
'js/views/group_edit'
|
||||
'js/views/group_edit', 'js/views/utils/view_utils'
|
||||
],
|
||||
function(BaseView, _, $, gettext, GroupEdit) {
|
||||
function(BaseView, _, $, gettext, GroupEdit, ViewUtils) {
|
||||
'use strict';
|
||||
var GroupConfigurationEdit = BaseView.extend({
|
||||
tagName: 'div',
|
||||
@@ -112,7 +112,7 @@ function(BaseView, _, $, gettext, GroupEdit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.runOperationShowingMessage(
|
||||
ViewUtils.runOperationShowingMessage(
|
||||
gettext('Saving') + '…',
|
||||
function () {
|
||||
var dfd = $.Deferred();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
define([
|
||||
'js/views/baseview', 'jquery', 'js/views/group_configuration_details',
|
||||
'js/views/group_configuration_edit'
|
||||
'js/views/baseview', 'jquery', "gettext", 'js/views/group_configuration_details',
|
||||
'js/views/group_configuration_edit', "js/views/utils/view_utils"
|
||||
], function(
|
||||
BaseView, $, GroupConfigurationDetails, GroupConfigurationEdit
|
||||
BaseView, $, gettext, GroupConfigurationDetails, GroupConfigurationEdit, ViewUtils
|
||||
) {
|
||||
'use strict';
|
||||
var GroupConfigurationsItem = BaseView.extend({
|
||||
@@ -35,12 +35,12 @@ define([
|
||||
deleteConfiguration: function(event) {
|
||||
if(event && event.preventDefault) { event.preventDefault(); }
|
||||
var self = this;
|
||||
this.confirmThenRunOperation(
|
||||
ViewUtils.confirmThenRunOperation(
|
||||
gettext('Delete this Group Configuration?'),
|
||||
gettext('Deleting this Group Configuration is permanent and cannot be undone.'),
|
||||
gettext('Delete'),
|
||||
function() {
|
||||
return self.runOperationShowingMessage(
|
||||
return ViewUtils.runOperationShowingMessage(
|
||||
gettext('Deleting') + '…',
|
||||
function () {
|
||||
return self.model.destroy({ wait: true });
|
||||
|
||||
@@ -15,7 +15,9 @@ define(["jquery", "underscore", "gettext", "js/views/baseview"],
|
||||
modalName: 'basic',
|
||||
modalType: 'generic',
|
||||
modalSize: 'lg',
|
||||
title: ''
|
||||
title: '',
|
||||
// A list of class names, separated by space.
|
||||
viewSpecificClasses: ''
|
||||
}),
|
||||
|
||||
initialize: function() {
|
||||
@@ -39,7 +41,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview"],
|
||||
name: this.options.modalName,
|
||||
type: this.options.modalType,
|
||||
size: this.options.modalSize,
|
||||
title: this.options.title
|
||||
title: this.options.title,
|
||||
viewSpecificClasses: this.options.viewSpecificClasses
|
||||
}));
|
||||
this.addActionButtons();
|
||||
this.renderContents();
|
||||
|
||||
244
cms/static/js/views/modals/edit_outline_item.js
Normal file
244
cms/static/js/views/modals/edit_outline_item.js
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* The EditSectionXBlockModal is a Backbone view that shows an editor in a modal window.
|
||||
* It has nested views: for release date, due date and grading format.
|
||||
* It is invoked using the editXBlock method and uses xblock_info as a model,
|
||||
* and upon save parent invokes refresh function that fetches updated model and
|
||||
* re-renders edited course outline.
|
||||
*/
|
||||
define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/modals/base_modal',
|
||||
'date', 'js/views/utils/xblock_utils', 'js/utils/date_utils'
|
||||
],
|
||||
function(
|
||||
$, Backbone, _, gettext, BaseModal, date, XBlockViewUtils, DateUtils
|
||||
) {
|
||||
'use strict';
|
||||
var EditSectionXBlockModal, BaseDateView, ReleaseDateView, DueDateView,
|
||||
GradingView;
|
||||
|
||||
EditSectionXBlockModal = BaseModal.extend({
|
||||
events : {
|
||||
'click .action-save': 'save',
|
||||
'click .action-modes a': 'changeMode'
|
||||
},
|
||||
|
||||
options: $.extend({}, BaseModal.prototype.options, {
|
||||
modalName: 'edit-outline-item',
|
||||
modalType: 'edit-settings',
|
||||
addSaveButton: true,
|
||||
modalSize: 'med',
|
||||
viewSpecificClasses: 'confirm'
|
||||
}),
|
||||
|
||||
initialize: function() {
|
||||
BaseModal.prototype.initialize.call(this);
|
||||
this.events = _.extend({}, BaseModal.prototype.events, this.events);
|
||||
this.template = this.loadTemplate('edit-outline-item-modal');
|
||||
this.options.title = this.getTitle();
|
||||
this.initializeComponents();
|
||||
},
|
||||
|
||||
getTitle: function () {
|
||||
if (this.model.isChapter() || this.model.isSequential()) {
|
||||
return _.template(
|
||||
gettext('<%= sectionName %> Settings'),
|
||||
{sectionName: this.model.get('display_name')});
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
|
||||
getContentHtml: function() {
|
||||
return this.template(this.getContext());
|
||||
},
|
||||
|
||||
afterRender: function() {
|
||||
BaseModal.prototype.render.apply(this, arguments);
|
||||
this.invokeComponentMethod('afterRender');
|
||||
},
|
||||
|
||||
save: function(event) {
|
||||
event.preventDefault();
|
||||
var requestData = _.extend({}, this.getRequestData(), {
|
||||
metadata: this.getMetadata()
|
||||
});
|
||||
XBlockViewUtils.updateXBlockFields(this.model, requestData, {
|
||||
success: this.options.onSave
|
||||
});
|
||||
this.hide();
|
||||
},
|
||||
|
||||
/**
|
||||
* Call the method on each value in the list. If the element of the
|
||||
* list doesn't have such a method it will be skipped.
|
||||
* @param {String} methodName The method name needs to be called.
|
||||
* @return {Object}
|
||||
*/
|
||||
invokeComponentMethod: function (methodName) {
|
||||
var values = _.map(this.components, function (component) {
|
||||
if (_.isFunction(component[methodName])) {
|
||||
return component[methodName].call(component);
|
||||
}
|
||||
});
|
||||
|
||||
return _.extend.apply(this, [{}].concat(values));
|
||||
},
|
||||
|
||||
/**
|
||||
* Return context for the modal.
|
||||
* @return {Object}
|
||||
*/
|
||||
getContext: function () {
|
||||
return _.extend({
|
||||
xblockInfo: this.model
|
||||
}, this.invokeComponentMethod('getContext'));
|
||||
},
|
||||
|
||||
/**
|
||||
* Return request data.
|
||||
* @return {Object}
|
||||
*/
|
||||
getRequestData: function () {
|
||||
return this.invokeComponentMethod('getRequestData');
|
||||
},
|
||||
|
||||
/**
|
||||
* Return metadata for the XBlock.
|
||||
* @return {Object}
|
||||
*/
|
||||
getMetadata: function () {
|
||||
return this.invokeComponentMethod('getMetadata');
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize internal components.
|
||||
*/
|
||||
initializeComponents: function () {
|
||||
this.components = [];
|
||||
this.components.push(
|
||||
new ReleaseDateView({
|
||||
selector: '.scheduled-date-input',
|
||||
parentView: this,
|
||||
model: this.model
|
||||
})
|
||||
);
|
||||
|
||||
if (this.model.isSequential()) {
|
||||
this.components.push(
|
||||
new DueDateView({
|
||||
selector: '.due-date-input',
|
||||
parentView: this,
|
||||
model: this.model
|
||||
}),
|
||||
new GradingView({
|
||||
selector: '.edit-settings-grading',
|
||||
parentView: this,
|
||||
model: this.model
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
BaseDateView = Backbone.View.extend({
|
||||
// Attribute name in the model, should be defined in children classes.
|
||||
fieldName: null,
|
||||
|
||||
events : {
|
||||
'click .clear-date': 'clearValue'
|
||||
},
|
||||
|
||||
afterRender: function () {
|
||||
this.setElement(this.options.parentView.$(this.options.selector).get(0));
|
||||
this.$('input.date').datepicker({'dateFormat': 'm/d/yy'});
|
||||
this.$('input.time').timepicker({
|
||||
'timeFormat' : 'H:i',
|
||||
'forceRoundTime': true
|
||||
});
|
||||
if (this.model.get(this.fieldName)) {
|
||||
DateUtils.setDate(
|
||||
this.$('input.date'), this.$('input.time'),
|
||||
this.model.get(this.fieldName)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
DueDateView = BaseDateView.extend({
|
||||
fieldName: 'due',
|
||||
|
||||
getValue: function () {
|
||||
return DateUtils.getDate(this.$('#due_date'), this.$('#due_time'));
|
||||
},
|
||||
|
||||
clearValue: function (event) {
|
||||
event.preventDefault();
|
||||
this.$('#due_time, #due_date').val('');
|
||||
},
|
||||
|
||||
getMetadata: function () {
|
||||
return {
|
||||
'due': this.getValue()
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ReleaseDateView = BaseDateView.extend({
|
||||
fieldName: 'start',
|
||||
startingReleaseDate: null,
|
||||
|
||||
afterRender: function () {
|
||||
BaseDateView.prototype.afterRender.call(this);
|
||||
// Store the starting date and time so that we can determine if the user
|
||||
// actually changed it when "Save" is pressed.
|
||||
this.startingReleaseDate = this.getValue();
|
||||
},
|
||||
|
||||
getValue: function () {
|
||||
return DateUtils.getDate(this.$('#start_date'), this.$('#start_time'));
|
||||
},
|
||||
|
||||
clearValue: function (event) {
|
||||
event.preventDefault();
|
||||
this.$('#start_time, #start_date').val('');
|
||||
},
|
||||
|
||||
getMetadata: function () {
|
||||
var newReleaseDate = this.getValue();
|
||||
if (JSON.stringify(newReleaseDate) === JSON.stringify(this.startingReleaseDate)) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
'start': newReleaseDate
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
GradingView = Backbone.View.extend({
|
||||
afterRender: function () {
|
||||
this.setElement(this.options.parentView.$(this.options.selector).get(0));
|
||||
this.setValue(this.model.get('format'));
|
||||
},
|
||||
|
||||
setValue: function (value) {
|
||||
this.$('#grading_type').val(value);
|
||||
},
|
||||
|
||||
getValue: function () {
|
||||
return this.$('#grading_type').val();
|
||||
},
|
||||
|
||||
getRequestData: function () {
|
||||
return {
|
||||
'graderType': this.getValue()
|
||||
};
|
||||
},
|
||||
|
||||
getContext: function () {
|
||||
return {
|
||||
graderTypes: JSON.parse(this.model.get('course_graders'))
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return EditSectionXBlockModal;
|
||||
});
|
||||
@@ -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",
|
||||
@@ -14,7 +14,8 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal",
|
||||
|
||||
options: $.extend({}, BaseModal.prototype.options, {
|
||||
modalName: 'edit-xblock',
|
||||
addSaveButton: true
|
||||
addSaveButton: true,
|
||||
viewSpecificClasses: 'modal-editor confirm'
|
||||
}),
|
||||
|
||||
initialize: function() {
|
||||
@@ -147,10 +148,19 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal",
|
||||
},
|
||||
|
||||
save: function(event) {
|
||||
var self = this,
|
||||
editorView = this.editorView,
|
||||
xblockInfo = this.xblockInfo,
|
||||
data = editorView.getXModuleData();
|
||||
event.preventDefault();
|
||||
this.editorView.save({
|
||||
success: _.bind(this.onSave, this)
|
||||
});
|
||||
if (data) {
|
||||
ViewUtils.runOperationShowingMessage(gettext('Saving…'),
|
||||
function() {
|
||||
return xblockInfo.save(data);
|
||||
}).done(function() {
|
||||
self.onSave();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onSave: function() {
|
||||
@@ -177,7 +187,8 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal",
|
||||
if (xblockWrapperElement.length > 0) {
|
||||
xblockElement = xblockWrapperElement.find('.xblock');
|
||||
displayName = xblockWrapperElement.find('.xblock-header .header-details .xblock-display-name').text().trim();
|
||||
// If not found, try looking for the old unit page style rendering
|
||||
// If not found, try looking for the old unit page style rendering.
|
||||
// Only used now by static pages.
|
||||
if (!displayName) {
|
||||
displayName = this.xblockElement.find('.component-header').text().trim();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
define(["domReady", "jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notification", "js/utils/drag_and_drop",
|
||||
"js/utils/cancel_on_escape", "js/utils/get_date", "js/utils/module"],
|
||||
function (domReady, $, ui, _, gettext, NotificationView, ContentDragger, CancelOnEscape,
|
||||
define(["domReady", "jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notification",
|
||||
"js/utils/cancel_on_escape", "js/utils/date_utils", "js/utils/module"],
|
||||
function (domReady, $, ui, _, gettext, NotificationView, CancelOnEscape,
|
||||
DateUtils, ModuleUtils) {
|
||||
|
||||
var modalSelector = '.edit-section-publish-settings';
|
||||
@@ -31,15 +31,15 @@ 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');
|
||||
};
|
||||
|
||||
|
||||
var closeModalNew = function (e) {
|
||||
if (e) {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
};
|
||||
}
|
||||
$('body').removeClass('modal-window-is-shown');
|
||||
$('.edit-section-publish-settings').removeClass('is-shown');
|
||||
};
|
||||
@@ -61,7 +61,7 @@ define(["domReady", "jquery", "jquery.ui", "underscore", "gettext", "js/views/fe
|
||||
var saveSetSectionScheduleDate = function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
var datetime = DateUtils(
|
||||
var datetime = DateUtils.getDate(
|
||||
$('.edit-section-publish-settings .start-date'),
|
||||
$('.edit-section-publish-settings .start-time')
|
||||
);
|
||||
@@ -230,27 +230,6 @@ define(["domReady", "jquery", "jquery.ui", "underscore", "gettext", "js/views/fe
|
||||
$('.new-courseware-section-button').bind('click', addNewSection);
|
||||
$('.new-subsection-item').bind('click', addNewSubsection);
|
||||
|
||||
// Section
|
||||
ContentDragger.makeDraggable(
|
||||
'.courseware-section',
|
||||
'.section-drag-handle',
|
||||
'.courseware-overview',
|
||||
'article.courseware-overview'
|
||||
);
|
||||
// Subsection
|
||||
ContentDragger.makeDraggable(
|
||||
'.id-holder',
|
||||
'.subsection-drag-handle',
|
||||
'.subsection-list > ol',
|
||||
'.courseware-section'
|
||||
);
|
||||
// Unit
|
||||
ContentDragger.makeDraggable(
|
||||
'.unit',
|
||||
'.unit-drag-handle',
|
||||
'ol.sortable-unit-list',
|
||||
'li.courseware-subsection, article.subsection-body'
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
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,31 +2,83 @@
|
||||
* 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"],
|
||||
function ($, _, gettext, BaseView, ContainerView, XBlockView, AddXBlockComponent, EditXBlockModal, XBlockInfo) {
|
||||
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
|
||||
|
||||
options: {
|
||||
collapsedClass: 'is-collapsed'
|
||||
},
|
||||
|
||||
view: 'container_preview',
|
||||
|
||||
initialize: function() {
|
||||
BaseView.prototype.initialize.call(this);
|
||||
initialize: function(options) {
|
||||
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-edit').click();
|
||||
}
|
||||
this.xblockView = new ContainerView({
|
||||
el: this.$('.wrapper-xblock'),
|
||||
model: this.model,
|
||||
view: this.view
|
||||
});
|
||||
this.messageView = new ContainerSubviews.MessageView({
|
||||
el: this.$('.container-message'),
|
||||
model: this.model
|
||||
});
|
||||
this.messageView.render();
|
||||
this.isUnitPage = this.options.isUnitPage;
|
||||
if (this.isUnitPage) {
|
||||
this.xblockPublisher = new ContainerSubviews.Publisher({
|
||||
el: this.$('#publish-unit'),
|
||||
model: this.model,
|
||||
// When "Discard Changes" is clicked, the whole page must be re-rendered.
|
||||
renderPage: this.render
|
||||
});
|
||||
this.xblockPublisher.render();
|
||||
|
||||
this.publishHistory = new ContainerSubviews.PublishHistory({
|
||||
el: this.$('#publish-history'),
|
||||
model: this.model
|
||||
});
|
||||
this.publishHistory.render();
|
||||
|
||||
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();
|
||||
}
|
||||
},
|
||||
|
||||
render: function(options) {
|
||||
var self = this,
|
||||
xblockView = this.xblockView,
|
||||
loadingElement = this.$('.ui-loading');
|
||||
loadingElement.removeClass('is-hidden');
|
||||
loadingElement = this.$('.ui-loading'),
|
||||
unitLocationTree = this.$('.unit-location'),
|
||||
hiddenCss='is-hidden';
|
||||
|
||||
loadingElement.removeClass(hiddenCss);
|
||||
|
||||
// Hide both blocks until we know which one to show
|
||||
xblockView.$el.addClass('is-hidden');
|
||||
xblockView.$el.addClass(hiddenCss);
|
||||
|
||||
if (!options || !options.refresh) {
|
||||
// Add actions to any top level buttons, e.g. "Edit" of the container itself.
|
||||
@@ -36,13 +88,14 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/contai
|
||||
|
||||
// Render the xblock
|
||||
xblockView.render({
|
||||
success: function(xblock) {
|
||||
success: function() {
|
||||
xblockView.xblock.runtime.notify("page-shown", self);
|
||||
xblockView.$el.removeClass('is-hidden');
|
||||
xblockView.$el.removeClass(hiddenCss);
|
||||
self.renderAddXBlockComponents();
|
||||
self.onXBlockRefresh(xblockView);
|
||||
self.refreshTitle();
|
||||
loadingElement.addClass('is-hidden');
|
||||
self.refreshDisplayName();
|
||||
loadingElement.addClass(hiddenCss);
|
||||
unitLocationTree.removeClass(hiddenCss);
|
||||
self.delegateEvents();
|
||||
}
|
||||
});
|
||||
@@ -56,15 +109,16 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/contai
|
||||
return this.xblockView.model.urlRoot;
|
||||
},
|
||||
|
||||
refreshTitle: function() {
|
||||
var title = this.$('.xblock-header .header-details .xblock-display-name').first().text().trim();
|
||||
this.$('.page-header-title').text(title);
|
||||
this.$('.page-header .subtitle a').last().text(title);
|
||||
refreshDisplayName: function() {
|
||||
var displayName = this.$('.xblock-header .header-details .xblock-display-name').first().text().trim();
|
||||
this.model.set('display_name', displayName);
|
||||
},
|
||||
|
||||
onXBlockRefresh: function(xblockView) {
|
||||
this.addButtonActions(xblockView.$el);
|
||||
this.xblockView.refresh();
|
||||
// Update publish and last modified information from the server.
|
||||
this.model.fetch();
|
||||
},
|
||||
|
||||
renderAddXBlockComponents: function() {
|
||||
@@ -113,7 +167,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
|
||||
@@ -132,9 +186,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 = {
|
||||
@@ -151,20 +205,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) {
|
||||
@@ -173,12 +220,13 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/contai
|
||||
xblock = xblockView.xblock,
|
||||
parent = this.findXBlockElement(xblockElement.parent());
|
||||
xblockElement.remove();
|
||||
xblockView.updateChildren(parent);
|
||||
xblock.runtime.notify('deleted-child', parent.data('locator'));
|
||||
// Update publish and last modified information from the server.
|
||||
this.model.fetch();
|
||||
},
|
||||
|
||||
onNewXBlock: function(xblockElement, scrollOffset, data) {
|
||||
this.setScrollOffset(xblockElement, scrollOffset);
|
||||
ViewUtils.setScrollOffset(xblockElement, scrollOffset);
|
||||
xblockElement.data('locator', data.locator);
|
||||
return this.refreshXBlock(xblockElement);
|
||||
},
|
||||
@@ -206,7 +254,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,
|
||||
|
||||
252
cms/static/js/views/pages/container_subviews.js
Normal file
252
cms/static/js/views/pages/container_subviews.js
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Subviews (usually small side panels) for XBlockContainerPage.
|
||||
*/
|
||||
define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/view_utils",
|
||||
"js/views/utils/xblock_utils"],
|
||||
function ($, _, gettext, BaseView, ViewUtils, XBlockViewUtils) {
|
||||
var VisibilityState = XBlockViewUtils.VisibilityState,
|
||||
disabledCss = "is-disabled";
|
||||
|
||||
/**
|
||||
* A view that refreshes the view when certain values in the XBlockInfo have changed
|
||||
* after a server sync operation.
|
||||
*/
|
||||
var ContainerStateListenerView = BaseView.extend({
|
||||
|
||||
// takes XBlockInfo as a model
|
||||
initialize: function() {
|
||||
this.model.on('sync', this.onSync, this);
|
||||
},
|
||||
|
||||
onSync: function(model) {
|
||||
if (this.shouldRefresh(model)) {
|
||||
this.render();
|
||||
}
|
||||
},
|
||||
|
||||
shouldRefresh: function(model) {
|
||||
return false;
|
||||
},
|
||||
|
||||
render: function() {}
|
||||
});
|
||||
|
||||
var MessageView = ContainerStateListenerView.extend({
|
||||
initialize: function () {
|
||||
ContainerStateListenerView.prototype.initialize.call(this);
|
||||
this.template = this.loadTemplate('container-message');
|
||||
},
|
||||
|
||||
shouldRefresh: function(model) {
|
||||
return ViewUtils.hasChangedAttributes(model, ['currently_visible_to_students']);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
this.$el.html(this.template({
|
||||
currentlyVisibleToStudents: this.model.get('currently_visible_to_students')
|
||||
}));
|
||||
return this;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* A controller for updating the "View Live" and "Preview" buttons.
|
||||
*/
|
||||
var PreviewActionController = ContainerStateListenerView.extend({
|
||||
shouldRefresh: function(model) {
|
||||
return ViewUtils.hasChangedAttributes(model, ['has_changes', 'published']);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var previewAction = this.$el.find('.button-preview'),
|
||||
viewLiveAction = this.$el.find('.button-view');
|
||||
if (this.model.get('published')) {
|
||||
viewLiveAction.removeClass(disabledCss);
|
||||
}
|
||||
else {
|
||||
viewLiveAction.addClass(disabledCss);
|
||||
}
|
||||
if (this.model.get('has_changes') || !this.model.get('published')) {
|
||||
previewAction.removeClass(disabledCss);
|
||||
}
|
||||
else {
|
||||
previewAction.addClass(disabledCss);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Publisher is a view that supports the following:
|
||||
* 1) Publishing of a draft version of an xblock.
|
||||
* 2) Discarding of edits in a draft version.
|
||||
* 3) Display of who last edited the xblock, and when.
|
||||
* 4) Display of publish status (published, published with changes, changes with no published version).
|
||||
*/
|
||||
var Publisher = BaseView.extend({
|
||||
events: {
|
||||
'click .action-publish': 'publish',
|
||||
'click .action-discard': 'discardChanges',
|
||||
'click .action-staff-lock': 'toggleStaffLock'
|
||||
},
|
||||
|
||||
// takes XBlockInfo as a model
|
||||
|
||||
initialize: function () {
|
||||
BaseView.prototype.initialize.call(this);
|
||||
this.template = this.loadTemplate('publish-xblock');
|
||||
this.model.on('sync', this.onSync, this);
|
||||
this.renderPage = this.options.renderPage;
|
||||
},
|
||||
|
||||
onSync: function(model) {
|
||||
if (ViewUtils.hasChangedAttributes(model, [
|
||||
'has_changes', 'published', 'edited_on', 'edited_by', 'visibility_state'
|
||||
])) {
|
||||
this.render();
|
||||
}
|
||||
},
|
||||
|
||||
render: function () {
|
||||
this.$el.html(this.template({
|
||||
visibilityState: this.model.get('visibility_state'),
|
||||
visibilityClass: XBlockViewUtils.getXBlockVisibilityClass(this.model.get('visibility_state')),
|
||||
hasChanges: this.model.get('has_changes'),
|
||||
editedOn: this.model.get('edited_on'),
|
||||
editedBy: this.model.get('edited_by'),
|
||||
published: this.model.get('published'),
|
||||
publishedOn: this.model.get('published_on'),
|
||||
publishedBy: this.model.get('published_by'),
|
||||
released: this.model.get('released_to_students'),
|
||||
releaseDate: this.model.get('release_date'),
|
||||
releaseDateFrom: this.model.get('release_date_from')
|
||||
}));
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
publish: function (e) {
|
||||
var xblockInfo = this.model;
|
||||
if (e && e.preventDefault) {
|
||||
e.preventDefault();
|
||||
}
|
||||
ViewUtils.runOperationShowingMessage(gettext('Publishing…'),
|
||||
function () {
|
||||
return xblockInfo.save({publish: 'make_public'}, {patch: true});
|
||||
}).always(function() {
|
||||
xblockInfo.set("publish", null);
|
||||
}).done(function () {
|
||||
xblockInfo.fetch();
|
||||
});
|
||||
},
|
||||
|
||||
discardChanges: function (e) {
|
||||
var xblockInfo = this.model, renderPage = this.renderPage;
|
||||
if (e && e.preventDefault) {
|
||||
e.preventDefault();
|
||||
}
|
||||
ViewUtils.confirmThenRunOperation(gettext("Discard Changes"),
|
||||
gettext("Are you sure you want to revert to the last published version of the unit? You cannot undo this action."),
|
||||
gettext("Discard Changes"),
|
||||
function () {
|
||||
ViewUtils.runOperationShowingMessage(gettext('Discarding Changes…'),
|
||||
function () {
|
||||
return xblockInfo.save({publish: 'discard_changes'}, {patch: true});
|
||||
}).always(function() {
|
||||
xblockInfo.set("publish", null);
|
||||
}).done(function () {
|
||||
renderPage();
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
toggleStaffLock: function (e) {
|
||||
var xblockInfo = this.model, self=this, enableStaffLock,
|
||||
saveAndPublishStaffLock, revertCheckBox;
|
||||
if (e && e.preventDefault) {
|
||||
e.preventDefault();
|
||||
}
|
||||
enableStaffLock = xblockInfo.get('visibility_state') !== VisibilityState.staffOnly;
|
||||
|
||||
revertCheckBox = function() {
|
||||
self.checkStaffLock(!enableStaffLock);
|
||||
};
|
||||
|
||||
saveAndPublishStaffLock = function() {
|
||||
// Setting staff lock to null when disabled will delete the field from this xblock,
|
||||
// allowing it to use the inherited value instead of using false explicitly.
|
||||
return xblockInfo.save({
|
||||
publish: 'republish',
|
||||
metadata: {visible_to_staff_only: enableStaffLock ? true : null}},
|
||||
{patch: true}
|
||||
).always(function() {
|
||||
xblockInfo.set("publish", null);
|
||||
}).done(function () {
|
||||
xblockInfo.fetch();
|
||||
}).fail(function() {
|
||||
revertCheckBox();
|
||||
});
|
||||
};
|
||||
|
||||
this.checkStaffLock(enableStaffLock);
|
||||
if (enableStaffLock) {
|
||||
ViewUtils.runOperationShowingMessage(gettext('Hiding Unit from Students…'),
|
||||
_.bind(saveAndPublishStaffLock, self));
|
||||
} else {
|
||||
ViewUtils.confirmThenRunOperation(gettext("Make Visible to Students"),
|
||||
gettext("If the unit was previously published and released to students, any changes you made to the unit when it was hidden will now be visible to students. Do you want to proceed?"),
|
||||
gettext("Make Visible to Students"),
|
||||
function() {
|
||||
ViewUtils.runOperationShowingMessage(gettext('Making Visible to Students…'),
|
||||
_.bind(saveAndPublishStaffLock, self));
|
||||
},
|
||||
function() {
|
||||
// On cancel, revert the check in the check box
|
||||
revertCheckBox();
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
checkStaffLock: function(check) {
|
||||
this.$('.action-staff-lock i').removeClass('icon-check icon-check-empty');
|
||||
this.$('.action-staff-lock i').addClass(check ? 'icon-check' : 'icon-check-empty');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PublishHistory displays when and by whom the xblock was last published, if it ever was.
|
||||
*/
|
||||
var PublishHistory = BaseView.extend({
|
||||
// takes XBlockInfo as a model
|
||||
|
||||
initialize: function () {
|
||||
BaseView.prototype.initialize.call(this);
|
||||
this.template = this.loadTemplate('publish-history');
|
||||
this.model.on('sync', this.onSync, this);
|
||||
},
|
||||
|
||||
onSync: function(model) {
|
||||
if (ViewUtils.hasChangedAttributes(model, ['published', 'published_on', 'published_by'])) {
|
||||
this.render();
|
||||
}
|
||||
},
|
||||
|
||||
render: function () {
|
||||
this.$el.html(this.template({
|
||||
published: this.model.get('published'),
|
||||
published_on: this.model.get('published_on'),
|
||||
published_by: this.model.get('published_by')
|
||||
}));
|
||||
|
||||
return this;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
'MessageView': MessageView,
|
||||
'PreviewActionController': PreviewActionController,
|
||||
'Publisher': Publisher,
|
||||
'PublishHistory': PublishHistory
|
||||
};
|
||||
}); // end define();
|
||||
153
cms/static/js/views/pages/course_outline.js
Normal file
153
cms/static/js/views/pages/course_outline.js
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 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 expandedLocators, CourseOutlinePage;
|
||||
|
||||
CourseOutlinePage = BasePage.extend({
|
||||
// takes XBlockInfo as a model
|
||||
|
||||
events: {
|
||||
"click .button-toggle-expand-collapse": "toggleExpandCollapse"
|
||||
},
|
||||
|
||||
options: {
|
||||
collapsedClass: 'is-collapsed'
|
||||
},
|
||||
|
||||
initialize: function() {
|
||||
var self = this;
|
||||
this.initialState = this.options.initialState;
|
||||
BasePage.prototype.initialize.call(this);
|
||||
this.$('.button-new').click(function(event) {
|
||||
self.outlineView.handleAddEvent(event);
|
||||
});
|
||||
this.model.on('change', this.setCollapseExpandVisibility, this);
|
||||
},
|
||||
|
||||
setCollapseExpandVisibility: function() {
|
||||
var has_content = this.hasContent(),
|
||||
collapseExpandButton = $('.button-toggle-expand-collapse');
|
||||
if (has_content) {
|
||||
collapseExpandButton.removeClass('is-hidden');
|
||||
} else {
|
||||
collapseExpandButton.addClass('is-hidden');
|
||||
}
|
||||
},
|
||||
|
||||
renderPage: function() {
|
||||
var setInitialExpandState = function (xblockInfo, expandedLocators) {
|
||||
if (xblockInfo.isCourse() || xblockInfo.isChapter()) {
|
||||
expandedLocators.add(xblockInfo.get('id'));
|
||||
}
|
||||
};
|
||||
|
||||
this.setCollapseExpandVisibility();
|
||||
this.expandedLocators = expandedLocators;
|
||||
this.expandedLocators.clear();
|
||||
if (this.model.get('child_info')) {
|
||||
_.each(this.model.get('child_info').children, function (childXBlockInfo) {
|
||||
setInitialExpandState(childXBlockInfo, this.expandedLocators);
|
||||
}, this);
|
||||
}
|
||||
setInitialExpandState(this.model, this.expandedLocators);
|
||||
|
||||
if (this.initialState && this.initialState.expanded_locators) {
|
||||
this.expandedLocators.addAll(this.initialState.expanded_locators);
|
||||
}
|
||||
|
||||
this.outlineView = new CourseOutlineView({
|
||||
el: this.$('.outline'),
|
||||
model: this.model,
|
||||
isRoot: true,
|
||||
initialState: this.initialState,
|
||||
expandedLocators: this.expandedLocators
|
||||
});
|
||||
this.outlineView.render();
|
||||
this.outlineView.setViewState(this.initialState || {});
|
||||
return $.Deferred().resolve().promise();
|
||||
},
|
||||
|
||||
hasContent: function() {
|
||||
return this.model.hasChildren();
|
||||
},
|
||||
|
||||
toggleExpandCollapse: function(event) {
|
||||
var toggleButton = this.$('.button-toggle-expand-collapse'),
|
||||
collapse = toggleButton.hasClass('collapse-all');
|
||||
event.preventDefault();
|
||||
toggleButton.toggleClass('collapse-all expand-all');
|
||||
this.$('.list-sections > li').each(function(index, domElement) {
|
||||
var element = $(domElement);
|
||||
if (collapse) {
|
||||
element.addClass('is-collapsed');
|
||||
} else {
|
||||
element.removeClass('is-collapsed');
|
||||
}
|
||||
});
|
||||
if (this.model.get('child_info')) {
|
||||
_.each(this.model.get('child_info').children, function (childXBlockInfo) {
|
||||
if (collapse) {
|
||||
this.expandedLocators.remove(childXBlockInfo.get('id'));
|
||||
}
|
||||
else {
|
||||
this.expandedLocators.add(childXBlockInfo.get('id'));
|
||||
}
|
||||
}, this);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Represents the set of locators that should be expanded for the page.
|
||||
*/
|
||||
expandedLocators = {
|
||||
locators: [],
|
||||
|
||||
/**
|
||||
* Add the locator to the set if it is not already present.
|
||||
*/
|
||||
add: function (locator) {
|
||||
if (!this.contains(locator)) {
|
||||
this.locators.push(locator);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Accepts an array of locators and adds them all to the set if not already present.
|
||||
*/
|
||||
addAll: function(locators) {
|
||||
_.each(locators, function(locator) {
|
||||
this.add(locator);
|
||||
}, this);
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove the locator from the set if it is present.
|
||||
*/
|
||||
remove: function (locator) {
|
||||
var index = this.locators.indexOf(locator);
|
||||
if (index >= 0) {
|
||||
this.locators.splice(index, 1);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns true iff the locator is present in the set.
|
||||
*/
|
||||
contains: function (locator) {
|
||||
return this.locators.indexOf(locator) >= 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Clears all expanded locators from the set.
|
||||
*/
|
||||
clear: function () {
|
||||
this.locators = [];
|
||||
}
|
||||
};
|
||||
|
||||
return CourseOutlinePage;
|
||||
}); // end define();
|
||||
@@ -1,27 +1,27 @@
|
||||
define([
|
||||
'jquery', 'underscore', 'gettext', 'js/views/baseview',
|
||||
'jquery', 'underscore', 'gettext', 'js/views/pages/base_page',
|
||||
'js/views/group_configurations_list'
|
||||
],
|
||||
function ($, _, gettext, BaseView, GroupConfigurationsList) {
|
||||
function ($, _, gettext, BasePage, GroupConfigurationsList) {
|
||||
'use strict';
|
||||
var GroupConfigurationsPage = BaseView.extend({
|
||||
var GroupConfigurationsPage = BasePage.extend({
|
||||
initialize: function() {
|
||||
BaseView.prototype.initialize.call(this);
|
||||
BasePage.prototype.initialize.call(this);
|
||||
this.listView = new GroupConfigurationsList({
|
||||
collection: this.collection
|
||||
});
|
||||
},
|
||||
|
||||
render: function() {
|
||||
renderPage: function() {
|
||||
var hash = this.getLocationHash();
|
||||
this.hideLoadingIndicator();
|
||||
this.$('.content-primary').append(this.listView.render().el);
|
||||
this.addButtonActions();
|
||||
this.addWindowActions();
|
||||
if (hash) {
|
||||
// Strip leading '#' to get id string to match
|
||||
this.expandConfiguration(hash.replace('#', ''))
|
||||
this.expandConfiguration(hash.replace('#', ''));
|
||||
}
|
||||
return $.Deferred().resolve().promise();
|
||||
},
|
||||
|
||||
addButtonActions: function () {
|
||||
@@ -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.getListElement();
|
||||
// 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.getListElement();
|
||||
}
|
||||
}
|
||||
return ancestorView;
|
||||
}
|
||||
});
|
||||
|
||||
return UnitOutlineView;
|
||||
}); // end define();
|
||||
@@ -9,7 +9,8 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "jquery
|
||||
options: $.extend({}, BaseModal.prototype.options, {
|
||||
modalName: 'assetupload',
|
||||
modalSize: 'med',
|
||||
successMessageTimeout: 2000 // 2 seconds
|
||||
successMessageTimeout: 2000, // 2 seconds
|
||||
viewSpecificClasses: 'confirm'
|
||||
}),
|
||||
|
||||
initialize: function() {
|
||||
|
||||
167
cms/static/js/views/utils/view_utils.js
Normal file
167
cms/static/js/views/utils/view_utils.js
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* 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, collapsedClass) {
|
||||
// Support the old 'collapsed' option until fully switched over to is-collapsed
|
||||
if (!collapsedClass) {
|
||||
collapsedClass = 'collapsed';
|
||||
}
|
||||
target.closest('.expand-collapse').toggleClass('expand collapse');
|
||||
target.closest('.is-collapsible, .window').toggleClass(collapsedClass);
|
||||
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, onCancelCallback) {
|
||||
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) {
|
||||
if (onCancelCallback) {
|
||||
onCancelCallback();
|
||||
}
|
||||
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
|
||||
};
|
||||
});
|
||||
177
cms/static/js/views/utils/xblock_utils.js
Normal file
177
cms/static/js/views/utils/xblock_utils.js
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* 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, VisibilityState,
|
||||
getXBlockVisibilityClass, getXBlockListTypeClass, updateXBlockFields;
|
||||
|
||||
/**
|
||||
* Represents the possible visibility states for an xblock:
|
||||
*
|
||||
* live - the block and all of its descendants are live to students (excluding staff only)
|
||||
* Note: Live means both published and released.
|
||||
*
|
||||
* ready - the block is ready to go live and all of its descendants are live or ready (excluding staff only)
|
||||
* Note: content is ready when it is published and scheduled with a release date in the future.
|
||||
*
|
||||
* unscheduled - the block and all of its descendants have no release date (excluding staff only)
|
||||
* Note: it is valid for items to be published with no release date in which case they are unscheduled.
|
||||
*
|
||||
* needsAttention - the block or its descendants need attention
|
||||
* i.e. there is some content that is not fully live, ready, unscheduled or staff only.
|
||||
* For example: one subsection has draft content, or there's both unreleased and released content
|
||||
* in one section.
|
||||
*
|
||||
* staffOnly - all of the block's content is to be shown to staff only
|
||||
* Note: staff only items do not affect their parent's state.
|
||||
*/
|
||||
VisibilityState = {
|
||||
live: 'live',
|
||||
ready: 'ready',
|
||||
unscheduled: 'unscheduled',
|
||||
needsAttention: 'needs_attention',
|
||||
staffOnly: 'staff_only'
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param xblockType A string representing the type of the xblock to be deleted.
|
||||
* @returns {jQuery promise} A promise representing the deletion of the xblock.
|
||||
*/
|
||||
deleteXBlock = function(xblockInfo, xblockType) {
|
||||
var deletion = $.Deferred(),
|
||||
url = ModuleUtils.getUpdateUrl(xblockInfo.id),
|
||||
xblockType = xblockType || gettext('component');
|
||||
ViewUtils.confirmThenRunOperation(
|
||||
interpolate(gettext('Delete this %(xblock_type)s?'), { xblock_type: xblockType }, true),
|
||||
interpolate(
|
||||
gettext('Deleting this %(xblock_type)s is permanent and cannot be undone.'),
|
||||
{ xblock_type: xblockType }, true
|
||||
),
|
||||
interpolate(gettext('Yes, delete this %(xblock_type)s'), { xblock_type: xblockType }, true),
|
||||
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 {Backbone Model} xblockInfo The XBlockInfo model representing the xblock.
|
||||
* @param {String} 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 });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the specified fields of an xblock to a new values.
|
||||
* @param {Backbone Model} xblockInfo The XBlockInfo model representing the xblock.
|
||||
* @param {Object} xblockData Object representing xblock data as accepted on server.
|
||||
* @param {Object} [options] Hash with options.
|
||||
* @returns {jQuery promise} A promise representing the updating of the xblock values.
|
||||
*/
|
||||
updateXBlockFields = function(xblockInfo, xblockData, options) {
|
||||
options = _.extend({}, { patch: true }, options);
|
||||
return ViewUtils.runOperationShowingMessage(gettext('Saving…'),
|
||||
function() {
|
||||
return xblockInfo.save(xblockData, options);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the CSS class to represent the specified xblock visibility state.
|
||||
*/
|
||||
getXBlockVisibilityClass = function(visibilityState) {
|
||||
if (visibilityState === VisibilityState.staffOnly) {
|
||||
return 'is-staff-only';
|
||||
}
|
||||
if (visibilityState === VisibilityState.live) {
|
||||
return 'is-live';
|
||||
}
|
||||
if (visibilityState === VisibilityState.ready) {
|
||||
return 'is-ready';
|
||||
}
|
||||
if (visibilityState === VisibilityState.needsAttention) {
|
||||
return 'has-warnings';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
getXBlockListTypeClass = function (xblockType) {
|
||||
var listType = 'list-unknown';
|
||||
if (xblockType === 'course') {
|
||||
listType = 'list-sections';
|
||||
} else if (xblockType === 'section') {
|
||||
listType = 'list-subsections';
|
||||
} else if (xblockType === 'subsection') {
|
||||
listType = 'list-units';
|
||||
}
|
||||
return listType;
|
||||
};
|
||||
|
||||
return {
|
||||
'VisibilityState': VisibilityState,
|
||||
'addXBlock': addXBlock,
|
||||
'deleteXBlock': deleteXBlock,
|
||||
'updateXBlockField': updateXBlockField,
|
||||
'getXBlockVisibilityClass': getXBlockVisibilityClass,
|
||||
'getXBlockListTypeClass': getXBlockListTypeClass,
|
||||
'updateXBlockFields': updateXBlockFields
|
||||
};
|
||||
});
|
||||
@@ -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'),
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* XBlockEditorView displays the authoring view of an xblock, and allows the user to switch between
|
||||
* the available modes.
|
||||
*/
|
||||
define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js/views/xblock",
|
||||
"js/views/metadata", "js/collections/metadata", "jquery.inputnumber"],
|
||||
function ($, _, gettext, NotificationView, XBlockView, MetadataView, MetadataCollection) {
|
||||
define(["jquery", "underscore", "gettext", "js/views/xblock", "js/views/metadata", "js/collections/metadata",
|
||||
"jquery.inputnumber"],
|
||||
function ($, _, gettext, XBlockView, MetadataView, MetadataCollection) {
|
||||
|
||||
var XBlockEditorView = XBlockView.extend({
|
||||
// takes XBlockInfo as a model
|
||||
@@ -88,26 +88,6 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js
|
||||
return this.metadataEditor;
|
||||
},
|
||||
|
||||
save: function(options) {
|
||||
var xblockInfo = this.model,
|
||||
data,
|
||||
saving;
|
||||
data = this.getXModuleData();
|
||||
if (data) {
|
||||
saving = new NotificationView.Mini({
|
||||
title: gettext('Saving…')
|
||||
});
|
||||
saving.show();
|
||||
return xblockInfo.save(data).done(function() {
|
||||
var success = options.success;
|
||||
saving.hide();
|
||||
if (success) {
|
||||
success();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the data saved for the xmodule. Note that this *does not* work for XBlocks.
|
||||
*/
|
||||
|
||||
289
cms/static/js/views/xblock_outline.js
Normal file
289
cms/static/js/views/xblock_outline.js
Normal file
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* 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:
|
||||
* - 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
|
||||
|
||||
options: {
|
||||
collapsedClass: 'is-collapsed'
|
||||
},
|
||||
|
||||
templateName: 'xblock-outline',
|
||||
|
||||
initialize: function() {
|
||||
BaseView.prototype.initialize.call(this);
|
||||
this.initialState = this.options.initialState;
|
||||
this.expandedLocators = this.options.expandedLocators;
|
||||
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.onSync, this);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
this.renderTemplate();
|
||||
this.addButtonActions(this.$el);
|
||||
this.addNameEditor();
|
||||
if (this.shouldRenderChildren() && this.shouldExpandChildren()) {
|
||||
this.renderChildren();
|
||||
}
|
||||
else {
|
||||
this.renderedChildren = false;
|
||||
}
|
||||
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),
|
||||
xblockTypeDisplayName = this.getXBlockType(this.model.get('category'), this.parentInfo, true),
|
||||
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,
|
||||
visibilityClass: XBlockViewUtils.getXBlockVisibilityClass(xblockInfo.get('visibility_state')),
|
||||
typeListClass: XBlockViewUtils.getXBlockListTypeClass(xblockType),
|
||||
parentInfo: this.parentInfo,
|
||||
xblockType: xblockType,
|
||||
xblockTypeDisplayName: xblockTypeDisplayName,
|
||||
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;
|
||||
},
|
||||
|
||||
getListElement: function() {
|
||||
return this.$('> .outline-content > ol');
|
||||
},
|
||||
|
||||
addChildView: function(childView) {
|
||||
this.getListElement().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) {
|
||||
// The course outline page tracks expanded locators. The unit location sidebar does not.
|
||||
if (this.expandedLocators) {
|
||||
var locator = this.model.get('id');
|
||||
var wasExpanded = this.expandedLocators.contains(locator);
|
||||
if (wasExpanded) {
|
||||
this.expandedLocators.remove(locator);
|
||||
}
|
||||
else {
|
||||
this.expandedLocators.add(locator);
|
||||
}
|
||||
}
|
||||
// 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(_.bind(this.handleDeleteEvent, this));
|
||||
element.find('.button-new').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,
|
||||
expandedLocators: this.expandedLocators,
|
||||
template: this.template,
|
||||
parentView: parentView || this
|
||||
});
|
||||
},
|
||||
|
||||
getXBlockType: function(category, parentInfo, translate) {
|
||||
var xblockType = category;
|
||||
if (category === 'chapter') {
|
||||
xblockType = translate ? gettext('section') : 'section';
|
||||
} else if (category === 'sequential') {
|
||||
xblockType = translate ? gettext('subsection') : 'subsection';
|
||||
} else if (category === 'vertical' && (!parentInfo || parentInfo.get('category') === 'sequential')) {
|
||||
xblockType = translate ? gettext('unit') : 'unit';
|
||||
}
|
||||
return xblockType;
|
||||
},
|
||||
|
||||
onSync: function(event) {
|
||||
if (ViewUtils.hasChangedAttributes(this.model, ['visibility_state', 'child_info', 'display_name'])) {
|
||||
this.onXBlockChange();
|
||||
}
|
||||
},
|
||||
|
||||
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 + '"]');
|
||||
}
|
||||
if (locatorElement.length > 0) {
|
||||
ViewUtils.setScrollOffset(locatorElement, scrollOffset);
|
||||
} else {
|
||||
console.error("Failed to show item with locator " + locatorToShow + "");
|
||||
}
|
||||
if (editDisplayName) {
|
||||
locatorElement.find('> div[class$="header"] .xblock-field-value-edit').click();
|
||||
}
|
||||
}
|
||||
this.initialState = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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();
|
||||
},
|
||||
|
||||
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();
|
||||
},
|
||||
|
||||
handleDeleteEvent: function(event) {
|
||||
var self = this,
|
||||
parentView = this.parentView;
|
||||
event.preventDefault();
|
||||
var xblockType = this.getXBlockType(this.model.get('category'), parentView.model, true);
|
||||
XBlockViewUtils.deleteXBlock(this.model, xblockType).done(function() {
|
||||
if (parentView) {
|
||||
parentView.onChildDeleted(self, event);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
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();
|
||||
126
cms/static/js/views/xblock_string_field_editor.js
Normal file
126
cms/static/js/views/xblock_string_field_editor.js
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* XBlockStringFieldEditor is a view that allows the user to inline edit an XBlock string field.
|
||||
* Clicking on the field value will hide the text and replace it with an input to allow the user
|
||||
* to change the value. Once the user leaves the field, a request will be sent to update the
|
||||
* 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(["js/views/baseview", "js/views/utils/xblock_utils"],
|
||||
function (BaseView, XBlockViewUtils) {
|
||||
|
||||
var XBlockStringFieldEditor = BaseView.extend({
|
||||
events: {
|
||||
'click .xblock-field-value-edit': 'showInput',
|
||||
'click button[name=submit]': 'onClickSubmit',
|
||||
'click button[name=cancel]': 'onClickCancel',
|
||||
'click .xblock-string-field-editor': 'onClickEditor',
|
||||
'change .xblock-field-input': 'updateField',
|
||||
'focusout .xblock-field-input': 'onInputFocusLost',
|
||||
'keyup .xblock-field-input': 'handleKeyUp'
|
||||
},
|
||||
|
||||
// takes XBlockInfo as a model
|
||||
|
||||
initialize: function() {
|
||||
BaseView.prototype.initialize.call(this);
|
||||
this.fieldName = this.$el.data('field');
|
||||
this.fieldDisplayName = this.$el.data('field-display-name');
|
||||
this.template = this.loadTemplate('xblock-string-field-editor');
|
||||
this.model.on('change:' + this.fieldName, this.onChangeField, this);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
this.$el.append(this.template({
|
||||
value: this.model.escape(this.fieldName),
|
||||
fieldName: this.fieldName,
|
||||
fieldDisplayName: this.fieldDisplayName
|
||||
}));
|
||||
return this;
|
||||
},
|
||||
|
||||
getLabel: function() {
|
||||
return this.$('.xblock-field-value');
|
||||
},
|
||||
|
||||
getInput: function () {
|
||||
return this.$('.xblock-field-input');
|
||||
},
|
||||
|
||||
onInputFocusLost: function() {
|
||||
var currentValue = this.model.get(this.fieldName);
|
||||
if (currentValue === this.getInput().val()) {
|
||||
this.hideInput();
|
||||
}
|
||||
},
|
||||
|
||||
onClickSubmit: function(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.updateField();
|
||||
},
|
||||
|
||||
onClickCancel: function(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.cancelInput();
|
||||
},
|
||||
|
||||
onClickEditor: function(event) {
|
||||
event.stopPropagation();
|
||||
},
|
||||
|
||||
onChangeField: function() {
|
||||
var value = this.model.get(this.fieldName);
|
||||
this.getLabel().text(value);
|
||||
this.getInput().val(value);
|
||||
this.hideInput();
|
||||
},
|
||||
|
||||
showInput: function(event) {
|
||||
var input = this.getInput();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.$el.addClass('is-editing');
|
||||
input.focus().select();
|
||||
},
|
||||
|
||||
hideInput: function() {
|
||||
this.$el.removeClass('is-editing');
|
||||
},
|
||||
|
||||
cancelInput: function() {
|
||||
this.getInput().val(this.model.get(this.fieldName));
|
||||
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 self = this,
|
||||
xblockInfo = this.model,
|
||||
newValue = this.getInput().val().trim(),
|
||||
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;
|
||||
}
|
||||
return XBlockViewUtils.updateXBlockField(xblockInfo, this.fieldName, newValue).done(function() {
|
||||
self.refresh();
|
||||
});
|
||||
},
|
||||
|
||||
handleKeyUp: function(event) {
|
||||
if (event.keyCode === 27) { // Revert the changes if the user hits escape
|
||||
this.cancelInput();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return XBlockStringFieldEditor;
|
||||
}); // end define();
|
||||
@@ -240,262 +240,6 @@ p, ul, ol, dl {
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// layout - basic
|
||||
.wrapper-view {
|
||||
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// layout - basic page header
|
||||
.wrapper-mast {
|
||||
margin: ($baseline*1.5) 0 0 0;
|
||||
padding: 0 $baseline;
|
||||
position: relative;
|
||||
|
||||
.mast, .metadata {
|
||||
@include clearfix();
|
||||
position: relative;
|
||||
max-width: $fg-max-width;
|
||||
min-width: $fg-min-width;
|
||||
width: flex-grid(12);
|
||||
margin: 0 auto $baseline auto;
|
||||
color: $gray-d2;
|
||||
}
|
||||
|
||||
.mast {
|
||||
border-bottom: 1px solid $gray-l4;
|
||||
padding-bottom: ($baseline/2);
|
||||
|
||||
// layout with actions
|
||||
.page-header {
|
||||
width: flex-grid(12);
|
||||
}
|
||||
|
||||
// layout with actions
|
||||
&.has-actions {
|
||||
@include clearfix();
|
||||
|
||||
.page-header {
|
||||
float: left;
|
||||
width: flex-grid(6,12);
|
||||
margin-right: flex-gutter();
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
position: relative;
|
||||
bottom: -($baseline*0.75);
|
||||
float: right;
|
||||
width: flex-grid(6,12);
|
||||
text-align: right;
|
||||
|
||||
.nav-item {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
margin-right: ($baseline/2);
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// buttons
|
||||
.button {
|
||||
padding: ($baseline/4) ($baseline/2) ($baseline/3) ($baseline/2);
|
||||
}
|
||||
|
||||
.new-button {
|
||||
|
||||
}
|
||||
|
||||
.view-button {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// layout with actions
|
||||
&.has-subtitle {
|
||||
|
||||
.nav-actions {
|
||||
bottom: -($baseline*1.5);
|
||||
}
|
||||
}
|
||||
|
||||
// layout with navigation
|
||||
&.has-navigation {
|
||||
|
||||
.nav-actions {
|
||||
bottom: -($baseline*1.5);
|
||||
}
|
||||
|
||||
.navigation-link {
|
||||
@extend %cont-truncated;
|
||||
display: inline-block;
|
||||
vertical-align: bottom; // correct for extra padding in FF
|
||||
max-width: 250px;
|
||||
|
||||
&.navigation-current {
|
||||
@extend %ui-disabled;
|
||||
color: $gray;
|
||||
max-width: 250px;
|
||||
|
||||
&:before {
|
||||
color: $gray;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-link:before {
|
||||
content: " / ";
|
||||
margin: ($baseline/4);
|
||||
color: $gray;
|
||||
|
||||
&:hover {
|
||||
color: $gray;
|
||||
}
|
||||
}
|
||||
|
||||
.navigation .navigation-link:first-child:before {
|
||||
content: "";
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// page metadata/action bar
|
||||
.metadata {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// layout - basic page content
|
||||
.wrapper-content {
|
||||
margin: 0;
|
||||
padding: 0 $baseline;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.content {
|
||||
@include clearfix();
|
||||
@extend %t-copy-base;
|
||||
max-width: $fg-max-width;
|
||||
min-width: $fg-min-width;
|
||||
width: flex-grid(12);
|
||||
margin: 0 auto;
|
||||
color: $gray-d2;
|
||||
|
||||
header {
|
||||
position: relative;
|
||||
margin-bottom: $baseline;
|
||||
border-bottom: 1px solid $gray-l4;
|
||||
padding-bottom: ($baseline/2);
|
||||
|
||||
.title-sub {
|
||||
@extend %t-copy-sub1;
|
||||
display: block;
|
||||
margin: 0;
|
||||
color: $gray-l2;
|
||||
}
|
||||
|
||||
.title-1 {
|
||||
@extend %t-title3;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-weight: 600;
|
||||
color: $gray-d3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-primary, .content-supplementary {
|
||||
@include box-sizing(border-box);
|
||||
}
|
||||
|
||||
// layout - primary content
|
||||
.content-primary {
|
||||
|
||||
.title-1 {
|
||||
@extend %t-title3;
|
||||
}
|
||||
|
||||
.title-2 {
|
||||
@extend %t-title4;
|
||||
margin: 0 0 ($baseline/2) 0;
|
||||
}
|
||||
|
||||
.title-3 {
|
||||
@extend %t-title6;
|
||||
margin: 0 0 ($baseline/2) 0;
|
||||
}
|
||||
|
||||
header {
|
||||
@include clearfix();
|
||||
|
||||
.title-2 {
|
||||
width: flex-grid(5, 12);
|
||||
margin: 0 flex-gutter() 0 0;
|
||||
float: left;
|
||||
}
|
||||
|
||||
.tip {
|
||||
@extend %t-copy-sub2;
|
||||
width: flex-grid(7, 12);
|
||||
float: right;
|
||||
margin-top: ($baseline/2);
|
||||
text-align: right;
|
||||
color: $gray-l2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// layout - supplemental content
|
||||
.content-supplementary {
|
||||
|
||||
> section {
|
||||
margin: 0 0 $baseline 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// layout - grandfathered
|
||||
.main-wrapper {
|
||||
position: relative;
|
||||
margin: 0 ($baseline*2);
|
||||
}
|
||||
|
||||
.inner-wrapper {
|
||||
@include clearfix();
|
||||
position: relative;
|
||||
max-width: 1280px;
|
||||
margin: auto;
|
||||
|
||||
> article {
|
||||
clear: both;
|
||||
}
|
||||
}
|
||||
|
||||
.main-column {
|
||||
clear: both;
|
||||
float: left;
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
float: right;
|
||||
width: 28%;
|
||||
}
|
||||
|
||||
.left {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.right {
|
||||
float: right;
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
|
||||
@@ -25,24 +25,18 @@
|
||||
|
||||
// ====================
|
||||
|
||||
.view-unit {
|
||||
// overriding outline styling for general drag and drop cases
|
||||
.outline-section, .outline-subsection {
|
||||
|
||||
.unit-location .draggable-drop-indicator {
|
||||
display: none; //needed to not show DnD UI (UI is shared across both views)
|
||||
// STATE: is dragging
|
||||
&.is-dragging {
|
||||
border-color: $gray-d3;
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// needed to override ui-window styling for dragging state (outline selectors get too specific)
|
||||
.courseware-section.is-dragging {
|
||||
box-shadow: 0 1px 2px 0 $shadow-d1 !important;
|
||||
border: 1px solid $gray-d3 !important;
|
||||
}
|
||||
|
||||
.courseware-section.is-dragging.valid-drop {
|
||||
border-color: $blue-s1 !important;
|
||||
box-shadow: 0 1px 2px 0 $blue-t2 !important;
|
||||
// STATE: is dragging + valid drop
|
||||
&.valid-drop {
|
||||
border-color: $ui-action-primary-color-focus;
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
@@ -81,4 +75,41 @@ body b {
|
||||
.CodeMirror {
|
||||
overflow: visible !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// Latex Compiler
|
||||
.wrapper-comp-editor.latex-problem {
|
||||
margin-top: ($baseline*2.5);
|
||||
}
|
||||
|
||||
.launch-latex-compiler {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
background-color: $white;
|
||||
padding: $baseline/2 0 $baseline/2 $baseline;
|
||||
border-bottom: 1px solid $gray-l2;
|
||||
}
|
||||
|
||||
// hides latex compiler button if settings mode is-active
|
||||
div.wrapper-comp-editor.is-inactive ~ div.launch-latex-compiler{
|
||||
display: none;
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// masthead button hidden states
|
||||
.mast.has-actions .nav-actions .button.is-hidden {
|
||||
@extend .is-hidden;
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// TODOs:
|
||||
|
||||
// * font-weight syncing
|
||||
// * divorce display: inline-block from base button definitions/placeholders
|
||||
// * canned typography class syncing
|
||||
|
||||
|
||||
@@ -160,6 +160,17 @@ $shadow-l2: rgba($black, 0.05);
|
||||
$shadow-d1: rgba($black, 0.4);
|
||||
$shadow-d2: rgba($black, 0.6);
|
||||
|
||||
// colors - application
|
||||
$color-draft: $gray-l3;
|
||||
$color-live: $blue;
|
||||
$color-ready: $green;
|
||||
$color-warning: $orange-l2;
|
||||
$color-error: $red-l2;
|
||||
$color-staff-only: $black;
|
||||
|
||||
$color-heading-base: $gray-d2;
|
||||
$color-copy-base: $gray-l1;
|
||||
|
||||
// ====================
|
||||
|
||||
// timing - used for animation/transition mixin syncing
|
||||
|
||||
@@ -247,3 +247,50 @@
|
||||
%anim-flashDouble {
|
||||
@include animation(flashDouble $tmg-f1 ease-in-out 1);
|
||||
}
|
||||
|
||||
|
||||
// ====================
|
||||
|
||||
|
||||
// pulse
|
||||
@include keyframes(pulse) {
|
||||
0% {
|
||||
opacity: 0.0;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
// canned animation - use if you want out of the box/non-customized anim
|
||||
%anim-pulse {
|
||||
@include animation(pulse $tmg-f1 ease-in-out 1);
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
|
||||
// was-dropped
|
||||
@include keyframes(was-dropped) {
|
||||
0% {
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
// canned animation - use if you want out of the box/non-customized anim
|
||||
%anim-was-dropped {
|
||||
@include animation(was-dropped $tmg-f1 ease-in-out 1);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// studio - elements - UI controls
|
||||
// ====================
|
||||
|
||||
// ====================
|
||||
|
||||
// general actions
|
||||
%action {
|
||||
@extend %ui-fake-link;
|
||||
@@ -16,6 +14,14 @@
|
||||
|
||||
// ====================
|
||||
|
||||
// general type and size
|
||||
%sizing {
|
||||
@extend %t-action4;
|
||||
padding: ($baseline/4) ($baseline/2) ($baseline/3) ($baseline/2);
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// gray primary button
|
||||
%btn-primary-gray {
|
||||
@extend %ui-btn-primary;
|
||||
@@ -46,8 +52,8 @@
|
||||
color: $white;
|
||||
|
||||
&:hover, &:active {
|
||||
background: $blue-s2;
|
||||
border-color: $blue-s2;
|
||||
background: $blue-l1;
|
||||
border-color: $blue-l1;
|
||||
}
|
||||
|
||||
&.current, &.active {
|
||||
@@ -219,15 +225,16 @@
|
||||
// ====================
|
||||
|
||||
// UI: element actions list
|
||||
%actions-header {
|
||||
.actions-list {
|
||||
display: inline-block;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
%actions-list {
|
||||
display: inline-block;
|
||||
margin-bottom: 0;
|
||||
|
||||
.action-item {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin: ($baseline/10) 0 ($baseline/10) ($baseline/10);
|
||||
|
||||
.action-button {
|
||||
@include transition(all $tmg-f3 linear 0s);
|
||||
@@ -249,6 +256,10 @@
|
||||
&.delete-button:hover {
|
||||
background-color: $gray-l1;
|
||||
}
|
||||
|
||||
[class^="icon-"] {
|
||||
font-style: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
@@ -259,10 +270,14 @@
|
||||
margin: 0;
|
||||
background: transparent url("../img/drag-handles.png") no-repeat right center;
|
||||
}
|
||||
|
||||
&.toggle-action {
|
||||
// TODO: generalize and move checkbox styling in from static-pages and assets sass
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UI: elem is collapsible
|
||||
// UI: elem is collapsible - TODO: this should be transitioned away from in favor of %ui-expand-collapse
|
||||
%expand-collapse {
|
||||
@include transition(all $tmg-f2 linear 0s);
|
||||
display: inline-block;
|
||||
@@ -288,6 +303,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
// UI: expand collapse
|
||||
%ui-expand-collapse {
|
||||
@include transition(all $tmg-f2 linear 0s);
|
||||
|
||||
|
||||
// CASE: default (is expanded)
|
||||
.ui-toggle-expansion {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
|
||||
.icon {
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
}
|
||||
|
||||
// STATE: hover/active
|
||||
&:hover, &:active {
|
||||
cursor: pointer;
|
||||
color: $ui-link-color-focus;
|
||||
}
|
||||
}
|
||||
|
||||
// CASE: is collapsed
|
||||
&.is-collapsed {
|
||||
|
||||
.ui-toggle-expansion {
|
||||
|
||||
.icon {
|
||||
@include transform(rotate(-90deg));
|
||||
@include transform-origin(50% 50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UI: drag handles
|
||||
.drag-handle {
|
||||
|
||||
@@ -298,7 +348,7 @@
|
||||
|
||||
// UI: elem is draggable
|
||||
.is-draggable {
|
||||
@include transition(border-color $tmg-f2 ease-in-out 0, box-shadow $tmg-f2 ease-in-out 0);
|
||||
@include transition(border-color $tmg-f2 ease-in-out 0, box-shadow $tmg-f2 ease-in-out 0, margin $tmg-f2 ease-in-out 0);
|
||||
position: relative;
|
||||
|
||||
.draggable-drop-indicator {
|
||||
@@ -338,8 +388,8 @@
|
||||
|
||||
// UI: condition - valid drop
|
||||
&.valid-drop {
|
||||
border-color: $blue-s1;
|
||||
box-shadow: 0 1px 2px 0 $blue-t2;
|
||||
border-color: $ui-action-primary-color-focus;
|
||||
box-shadow: 0 1px 2px 0 rgba($ui-action-primary-color-focus, 0.50);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,6 +402,7 @@
|
||||
.drop-target {
|
||||
|
||||
&.drop-target-before {
|
||||
margin-top: ($baseline*1.5);
|
||||
|
||||
> .draggable-drop-indicator-before {
|
||||
opacity: 1.0;
|
||||
@@ -359,9 +410,17 @@
|
||||
}
|
||||
|
||||
&.drop-target-after {
|
||||
margin-bottom: ($baseline*1.5);
|
||||
|
||||
> .draggable-drop-indicator-after {
|
||||
opacity: 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UI: drop state - was dropped
|
||||
.was-dropped {
|
||||
@include animation(was-dropped $tmg-avg ease-in-out 1);
|
||||
border-color: $ui-action-primary-color-focus;
|
||||
box-shadow: 0 1px 2px 0 rgba($ui-action-primary-color-focus, 0.50);
|
||||
}
|
||||
|
||||
@@ -326,6 +326,43 @@ form[class^="create-"] {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// form - inline xblock name edit on unit, container, outline
|
||||
|
||||
// TOOD: abstract this out into a Sass placeholder
|
||||
.incontext-editor.is-editable {
|
||||
|
||||
.incontext-editor-value,
|
||||
.incontext-editor-action-wrapper {
|
||||
@extend %cont-truncated;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.incontext-editor-open-action {
|
||||
@extend %ui-btn-non-blue;
|
||||
@extend %t-copy-base;
|
||||
padding-top: ($baseline/10);
|
||||
}
|
||||
|
||||
.incontext-editor-form {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.is-editing {
|
||||
|
||||
.incontext-editor-value,
|
||||
.incontext-editor-action-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.incontext-editor-form {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// forms - grandfathered
|
||||
|
||||
293
cms/static/sass/elements/_layout.scss
Normal file
293
cms/static/sass/elements/_layout.scss
Normal file
@@ -0,0 +1,293 @@
|
||||
// studio - elements - layouts
|
||||
// ====================
|
||||
|
||||
// layout - basic
|
||||
// the wrapper around the viewable page area, excluding modal and other extra-view content
|
||||
.wrapper-view {
|
||||
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// layout - basic page header
|
||||
.wrapper-mast {
|
||||
margin: ($baseline*1.5) 0 0 0;
|
||||
padding: 0 $baseline;
|
||||
position: relative;
|
||||
|
||||
.mast,
|
||||
.metadata {
|
||||
@include clearfix();
|
||||
position: relative;
|
||||
max-width: $fg-max-width;
|
||||
min-width: $fg-min-width;
|
||||
width: flex-grid(12);
|
||||
margin: 0 auto $baseline auto;
|
||||
color: $gray-d2;
|
||||
}
|
||||
|
||||
.mast {
|
||||
border-bottom: 1px solid $gray-l4;
|
||||
padding-bottom: ($baseline/2);
|
||||
|
||||
// layout without actions
|
||||
.page-header {
|
||||
width: flex-grid(12);
|
||||
}
|
||||
|
||||
// layout with actions
|
||||
&.has-actions {
|
||||
@include clearfix();
|
||||
|
||||
.page-header {
|
||||
float: left;
|
||||
width: flex-grid(6,12);
|
||||
margin-right: flex-gutter();
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
position: relative;
|
||||
bottom: -($baseline*0.75);
|
||||
float: right;
|
||||
width: flex-grid(6,12);
|
||||
text-align: right;
|
||||
|
||||
.nav-item {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
margin-right: ($baseline/2);
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// buttons
|
||||
.button {
|
||||
@extend %btn-primary-blue;
|
||||
@extend %sizing;
|
||||
|
||||
.action-button-text {
|
||||
display: inline-block;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
[class^="icon-"] {
|
||||
display: inline-block;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
// CASE: new/create button
|
||||
&.new-button,
|
||||
&.button-new {
|
||||
@extend %btn-primary-green;
|
||||
@extend %sizing;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// layout with actions
|
||||
&.has-subtitle {
|
||||
|
||||
.nav-actions {
|
||||
bottom: -($baseline*1.5);
|
||||
}
|
||||
}
|
||||
|
||||
// layout with breadcrumb navigation
|
||||
&.has-navigation {
|
||||
|
||||
.nav-actions {
|
||||
bottom: -($baseline*1.5);
|
||||
}
|
||||
|
||||
.navigation-item {
|
||||
@extend %cont-truncated;
|
||||
display: inline-block;
|
||||
vertical-align: bottom; // correct for extra padding in FF
|
||||
max-width: 250px;
|
||||
color: $gray;
|
||||
|
||||
&.navigation-current {
|
||||
@extend %ui-disabled;
|
||||
color: $gray;
|
||||
max-width: 250px;
|
||||
|
||||
&:before {
|
||||
color: $gray;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-link:hover {
|
||||
color: $blue;
|
||||
}
|
||||
|
||||
.navigation-item:before {
|
||||
content: " / ";
|
||||
margin: ($baseline/4);
|
||||
color: $gray;
|
||||
|
||||
&:hover {
|
||||
color: $gray;
|
||||
}
|
||||
}
|
||||
|
||||
.navigation .navigation-item:first-child:before {
|
||||
content: "";
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// page metadata/action bar
|
||||
.metadata {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// layout - basic page content
|
||||
.wrapper-content {
|
||||
margin: 0;
|
||||
padding: 0 $baseline;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.content {
|
||||
@include clearfix();
|
||||
@extend %t-copy-base;
|
||||
max-width: $fg-max-width;
|
||||
min-width: $fg-min-width;
|
||||
width: flex-grid(12);
|
||||
margin: 0 auto;
|
||||
color: $gray-d2;
|
||||
|
||||
header {
|
||||
position: relative;
|
||||
margin-bottom: $baseline;
|
||||
border-bottom: 1px solid $gray-l4;
|
||||
padding-bottom: ($baseline/2);
|
||||
|
||||
.title-sub {
|
||||
@extend %t-copy-sub1;
|
||||
display: block;
|
||||
margin: 0;
|
||||
color: $gray-l2;
|
||||
}
|
||||
|
||||
.title-1 {
|
||||
@extend %t-title3;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-weight: 600;
|
||||
color: $gray-d3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-primary,
|
||||
.content-supplementary {
|
||||
@include box-sizing(border-box);
|
||||
}
|
||||
|
||||
// 3/4 - 1/4 two col layout
|
||||
%two-col-1 {
|
||||
.content-primary {
|
||||
float: left;
|
||||
margin-right: flex-gutter();
|
||||
width: flex-grid(9,12);
|
||||
box-shadow: none;
|
||||
border: 0;
|
||||
background-color: $white;
|
||||
}
|
||||
|
||||
.content-supplementary {
|
||||
float: left;
|
||||
width: flex-grid(3,12);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// layout - primary content
|
||||
.content-primary {
|
||||
|
||||
.title-1 {
|
||||
@extend %t-title3;
|
||||
}
|
||||
|
||||
.title-2 {
|
||||
@extend %t-title4;
|
||||
margin: 0 0 ($baseline/2) 0;
|
||||
}
|
||||
|
||||
.title-3 {
|
||||
@extend %t-title6;
|
||||
margin: 0 0 ($baseline/2) 0;
|
||||
}
|
||||
|
||||
header {
|
||||
@include clearfix();
|
||||
|
||||
.title-2 {
|
||||
width: flex-grid(5, 12);
|
||||
margin: 0 flex-gutter() 0 0;
|
||||
float: left;
|
||||
}
|
||||
|
||||
.tip {
|
||||
@extend %t-copy-sub2;
|
||||
width: flex-grid(7, 12);
|
||||
float: right;
|
||||
margin-top: ($baseline/2);
|
||||
text-align: right;
|
||||
color: $gray-l2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// layout - supplemental content
|
||||
.content-supplementary {
|
||||
|
||||
> section {
|
||||
margin: 0 0 $baseline 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// layout - grandfathered
|
||||
.main-wrapper {
|
||||
position: relative;
|
||||
margin: 0 ($baseline*2);
|
||||
}
|
||||
|
||||
.inner-wrapper {
|
||||
@include clearfix();
|
||||
position: relative;
|
||||
max-width: 1280px;
|
||||
margin: auto;
|
||||
|
||||
> article {
|
||||
clear: both;
|
||||
}
|
||||
}
|
||||
|
||||
.main-column {
|
||||
clear: both;
|
||||
float: left;
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
float: right;
|
||||
width: 28%;
|
||||
}
|
||||
|
||||
.left {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.right {
|
||||
float: right;
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
[class*="view-"] {
|
||||
|
||||
// basic modal content
|
||||
// ------------------------
|
||||
.modal-window {
|
||||
@extend %ui-depth3;
|
||||
@include box-sizing(border-box);
|
||||
@@ -51,6 +52,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
// sections within a modal
|
||||
.modal-section {
|
||||
margin-bottom: ($baseline/2);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-section-title {
|
||||
@extend %t-title6;
|
||||
margin: 0 0 ($baseline/2) 0;
|
||||
border-bottom: 1px solid $gray-l4;
|
||||
padding-bottom: ($baseline/4);
|
||||
color: $gray-d2;
|
||||
}
|
||||
|
||||
.modal-section-content {
|
||||
|
||||
.list-fields, .list-actions {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.list-actions {
|
||||
@extend %actions-list;
|
||||
margin-left: ($baseline/4);
|
||||
|
||||
.action-button {
|
||||
@extend %t-icon4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: need to sync up (alongside general editing mode) with xblocks.scss UI
|
||||
.modal-chin,
|
||||
.xblock-actions,
|
||||
@@ -86,6 +121,7 @@
|
||||
|
||||
|
||||
// small modals - quick editors and dialogs
|
||||
// ------------------------
|
||||
.modal-sm {
|
||||
width: 30%;
|
||||
min-width: ($baseline*15);
|
||||
@@ -96,6 +132,7 @@
|
||||
}
|
||||
|
||||
// medium modals - forms and interactives
|
||||
// ------------------------
|
||||
.modal-med {
|
||||
width: 40%;
|
||||
min-width: ($baseline*18);
|
||||
@@ -106,11 +143,16 @@
|
||||
}
|
||||
|
||||
// large modals - component editors and interactives
|
||||
// ------------------------
|
||||
.modal-lg {
|
||||
width: 70%;
|
||||
min-width: ($baseline*27.5);
|
||||
height: auto;
|
||||
|
||||
.modal-content {
|
||||
padding: $baseline;
|
||||
}
|
||||
|
||||
&.modal-editor {
|
||||
|
||||
.modal-header {
|
||||
@@ -162,6 +204,7 @@
|
||||
|
||||
|
||||
// specific modal overrides
|
||||
// ------------------------
|
||||
|
||||
// upload modal
|
||||
.assetupload-modal {
|
||||
@@ -191,6 +234,88 @@
|
||||
}
|
||||
}
|
||||
|
||||
// edit outline item settings
|
||||
.edit-outline-item-modal {
|
||||
|
||||
.list-fields {
|
||||
|
||||
.field {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
margin-right: ($baseline/2);
|
||||
margin-bottom: ($baseline/4);
|
||||
|
||||
|
||||
// TODO: refactor the _forms.scss partial to allow for this area to inherit from it
|
||||
label, input, textarea {
|
||||
display: block;
|
||||
}
|
||||
|
||||
label {
|
||||
@extend %t-copy-sub1;
|
||||
@include transition(color $tmg-f3 ease-in-out 0s);
|
||||
margin: 0 0 ($baseline/4) 0;
|
||||
font-weight: 600;
|
||||
|
||||
&.is-focused {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
input, textarea {
|
||||
@extend %t-copy-base;
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: ($baseline/2);
|
||||
|
||||
// CASE: long length
|
||||
&.long {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// CASE: short length
|
||||
&.short {
|
||||
width: 25%;
|
||||
}
|
||||
}
|
||||
|
||||
// CASE: specific release + due times/dates
|
||||
.start-date,
|
||||
.start-time,
|
||||
.due-date,
|
||||
.due-time {
|
||||
width: ($baseline*7);
|
||||
}
|
||||
}
|
||||
|
||||
// CASE: select input
|
||||
.field-select {
|
||||
|
||||
.label, .input {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-right: ($baseline/2);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.edit-settings-grading {
|
||||
|
||||
.grading-type {
|
||||
margin-bottom: $baseline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// xblock custom actions
|
||||
.modal-window .editor-with-buttons {
|
||||
margin-bottom: ($baseline*3);
|
||||
|
||||
578
cms/static/sass/elements/_modules.scss
Normal file
578
cms/static/sass/elements/_modules.scss
Normal file
@@ -0,0 +1,578 @@
|
||||
// studio - elements - content modules
|
||||
// ====================
|
||||
// Patterns for pieces of content - modules - used throughout the app
|
||||
|
||||
// basic gray module with a strong top border and title, with related content box attached
|
||||
// --------------------
|
||||
%bar-module {
|
||||
margin-bottom: $baseline;
|
||||
border-top: 5px solid $gray-l1;
|
||||
background-color: $white;
|
||||
|
||||
.bar-mod-title {
|
||||
@extend %t-title6;
|
||||
display: block;
|
||||
padding: ($baseline/2) ($baseline*.75);
|
||||
background-color: $gray-l4;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bar-mod-content {
|
||||
@extend %t-copy-sub1;
|
||||
border-bottom: 1px solid $gray-l4;
|
||||
padding: ($baseline*.75) ($baseline*.75) $baseline ($baseline*.75);
|
||||
|
||||
.title {
|
||||
@extend %t-title8;
|
||||
margin-bottom: ($baseline/4);
|
||||
font-weight: 600;
|
||||
color: $gray-l2;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.meta {
|
||||
@extend %t-copy-sub2;
|
||||
color: $gray-l1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// blue bar and title bg version
|
||||
// --------------------
|
||||
%bar-module-blue {
|
||||
@extend %bar-module;
|
||||
border-top: 5px solid $blue;
|
||||
|
||||
.bar-mod-title {
|
||||
background-color: $blue-t0;
|
||||
}
|
||||
}
|
||||
|
||||
// green bar and title bg version
|
||||
// --------------------
|
||||
%bar-module-green {
|
||||
@extend %bar-module;
|
||||
border-top: 5px solid $green;
|
||||
|
||||
.bar-mod-title {
|
||||
background-color: $green-l5;
|
||||
}
|
||||
}
|
||||
|
||||
// yellow bar and title bg version
|
||||
// --------------------
|
||||
%bar-module-yellow {
|
||||
@extend %bar-module;
|
||||
border-top: 5px solid $orange-l2;
|
||||
|
||||
.bar-mod-title {
|
||||
background-color: $orange-l5;
|
||||
}
|
||||
}
|
||||
|
||||
// red bar and title bg version
|
||||
// --------------------
|
||||
%bar-module-red {
|
||||
@extend %bar-module;
|
||||
border-top: 5px solid $red-l2;
|
||||
|
||||
.bar-mod-title {
|
||||
background-color: $red-l5;
|
||||
}
|
||||
}
|
||||
|
||||
// black bar and title bg version
|
||||
%bar-module-black {
|
||||
@extend %bar-module;
|
||||
border-top: 5px solid $black;
|
||||
|
||||
.bar-mod-title {
|
||||
background-color: $gray-l4;
|
||||
}
|
||||
}
|
||||
|
||||
// Add new component menu with big green buttons
|
||||
// outermost wrapper for add a new component menu
|
||||
// --------------------
|
||||
.add-xblock-component {
|
||||
margin: $baseline ($baseline/2);
|
||||
border: 1px solid $gray-l3;
|
||||
border-radius: ($baseline/4);
|
||||
box-shadow: 0 1px 3px $shadow inset;
|
||||
background-color: $gray-l5;
|
||||
padding: ($baseline/2);
|
||||
|
||||
// add component menu inner wrapper
|
||||
.new-component {
|
||||
text-align: center;
|
||||
|
||||
h5 {
|
||||
@extend %t-title5;
|
||||
margin-bottom: ($baseline*.75);
|
||||
color: $green-d1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
// add component - list of green buttons
|
||||
.new-component-type {
|
||||
@include clearfix;
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
// green button
|
||||
.add-xblock-component-button {
|
||||
@extend %t-action3;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: ($baseline*5);
|
||||
height: ($baseline*5);
|
||||
margin-right: ($baseline*.75);
|
||||
margin-bottom: ($baseline/2);
|
||||
border: 1px solid $green-d2;
|
||||
border-radius: ($baseline/4);
|
||||
box-shadow: 0 1px 1px $shadow, 0 1px 0 rgba(255, 255, 255, .4) inset;
|
||||
background-color: $green-l1;
|
||||
text-align: center;
|
||||
color: $white;
|
||||
|
||||
&:hover {
|
||||
background: $green-s1;
|
||||
}
|
||||
|
||||
.name {
|
||||
@include box-sizing(border-box);
|
||||
display: block;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// outer most wrapper div for scroll up component picker menus
|
||||
// swaps in when a green button is clicked
|
||||
// --------------------
|
||||
.new-component-templates {
|
||||
@include clearfix;
|
||||
display: none;
|
||||
margin: $baseline ($baseline*2);
|
||||
border-radius: 3px;
|
||||
border: 1px solid $mediumGrey;
|
||||
background-color: $white;
|
||||
box-shadow: 0 1px 1px $shadow, 0 1px 0 rgba(255, 255, 255, .4) inset;
|
||||
|
||||
.cancel-button {
|
||||
@include white-button;
|
||||
margin: $baseline 0 ($baseline/2) ($baseline/2);
|
||||
}
|
||||
|
||||
.problem-type-tabs {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// specific menu types
|
||||
&.new-component-problem {
|
||||
padding-bottom: ($baseline/2);
|
||||
|
||||
.problem-type-tabs {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// individual menus
|
||||
// --------------------
|
||||
.new-component-template {
|
||||
@include clearfix;
|
||||
|
||||
li {
|
||||
border: none;
|
||||
border-bottom: 1px dashed $lightGrey;
|
||||
|
||||
&:first-child {
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
@include clearfix();
|
||||
@include transition(none);
|
||||
display: block;
|
||||
border: 0px;
|
||||
padding: 7px $baseline;
|
||||
background: $white;
|
||||
color: $gray-d3;
|
||||
font-weight: 500;
|
||||
|
||||
|
||||
&:hover {
|
||||
@include transition(background-color $tmg-f2 linear 0s);
|
||||
background: tint($green,30%);
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// basic and advanced problem tabs - also handled by jquery-ui tabs
|
||||
// --------------------
|
||||
.problem-type-tabs {
|
||||
@include linear-gradient(top, rgba(255, 255, 255, .4), rgba(255, 255, 255, 0));
|
||||
list-style-type: none;
|
||||
width: 100%;
|
||||
border-radius: 0;
|
||||
background-color: $lightBluishGrey;
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.2) inset, 0 -1px 0 $shadow inset;
|
||||
|
||||
li:first-child {
|
||||
margin-left: $baseline;
|
||||
}
|
||||
|
||||
li {
|
||||
@include linear-gradient(top, rgba(255, 255, 255, .4), rgba(255, 255, 255, 0));
|
||||
opacity: 0.8;
|
||||
float: left;
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.2) inset, 0 -1px 0 $shadow inset;
|
||||
background-color: tint($lightBluishGrey, 10%);
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
background-color: tint($lightBluishGrey, 20%);
|
||||
}
|
||||
|
||||
&.ui-state-active {
|
||||
@include active;
|
||||
border: 0px;
|
||||
opacity: 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
@extend %t-action3;
|
||||
display: block;
|
||||
padding: ($baseline*.75) ($baseline*1.25);
|
||||
text-align: center;
|
||||
color: $gray-d3;
|
||||
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// outline UI
|
||||
// --------------------
|
||||
|
||||
// outline: utilities
|
||||
$outline-indent-width: $baseline;
|
||||
|
||||
// UI: general outline
|
||||
.outline-content {
|
||||
margin-top: 5px;
|
||||
|
||||
.unit-content {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
// add/new items
|
||||
.add-item {
|
||||
margin-top: ($baseline*0.75);
|
||||
|
||||
.button-new {
|
||||
@extend %ui-btn-flat-outline;
|
||||
padding: ($baseline/2) $baseline;
|
||||
display: block;
|
||||
|
||||
.icon {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: ($baseline/2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// UI: section
|
||||
%outline-section {
|
||||
@include transition(border-left-width $tmg-f2 linear 0s, border-left-color $tmg-f2 linear 0s, padding-left $tmg-f2 linear 0s);
|
||||
border-left: 1px solid $color-draft;
|
||||
margin-bottom: $baseline;
|
||||
padding: ($baseline/4) ($baseline/2) ($baseline/2) ($baseline/2);
|
||||
|
||||
// STATE: is-collapsed
|
||||
&.is-collapsed {
|
||||
border-left-width: ($baseline/4);
|
||||
padding-left: $baseline;
|
||||
|
||||
// CASE: is ready to be live
|
||||
&.is-ready {
|
||||
border-left-color: $color-ready;
|
||||
}
|
||||
|
||||
// CASE: is live
|
||||
&.is-live {
|
||||
border-left-color: $color-live;
|
||||
}
|
||||
|
||||
// CASE: has staff-only content
|
||||
&.is-staff-only {
|
||||
border-left-color: $color-staff-only;
|
||||
}
|
||||
|
||||
// CASE: has unpublished content
|
||||
&.has-warnings {
|
||||
border-left-color: $color-warning;
|
||||
}
|
||||
|
||||
// CASE: has errors
|
||||
&.has-errors {
|
||||
border-left-color: $color-error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UI: subsection
|
||||
%outline-subsection {
|
||||
@include transition(border-left-color $tmg-f2 linear 0s);
|
||||
margin-bottom: ($baseline/2);
|
||||
border: 1px solid $gray-l4;
|
||||
border-left: ($baseline/4) solid $color-draft;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
padding: ($baseline/4) ($baseline/2) ($baseline/2) ($baseline/2);
|
||||
|
||||
// CASE: is ready to be live
|
||||
&.is-ready {
|
||||
border-left-color: $color-ready;
|
||||
}
|
||||
|
||||
// CASE: is live
|
||||
&.is-live {
|
||||
border-left-color: $color-live;
|
||||
}
|
||||
|
||||
// CASE: is presented for staff only
|
||||
&.is-staff-only {
|
||||
border-left-color: $color-staff-only;
|
||||
}
|
||||
|
||||
// CASE: has unpublished content
|
||||
&.has-warnings {
|
||||
border-left-color: $color-warning;
|
||||
}
|
||||
|
||||
// CASE: has errors
|
||||
&.has-errors {
|
||||
border-left-color: $color-error;
|
||||
}
|
||||
}
|
||||
|
||||
%outline-item {
|
||||
|
||||
// UI: item title
|
||||
.item-title {
|
||||
@include transition(color $tmg-f2 ease-in-out 0s);
|
||||
}
|
||||
|
||||
// CASE: last-child in UI
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
// CASE: has staff-only content
|
||||
&.is-staff-only {
|
||||
|
||||
// needed to make sure direct children only
|
||||
> .section-status,
|
||||
> .subsection-status,
|
||||
> .unit-status {
|
||||
|
||||
.status-message .icon {
|
||||
color: $color-staff-only;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CASE: has unpublished content
|
||||
&.has-warnings {
|
||||
|
||||
// needed to make sure direct children only
|
||||
> .section-status .status-message,
|
||||
> .subsection-status .status-message,
|
||||
> .unit-status .status-message {
|
||||
|
||||
.icon {
|
||||
color: $color-warning;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CASE: has errors
|
||||
&.has-errors {
|
||||
|
||||
// needed to make sure direct children only
|
||||
> .section-status .status-message,
|
||||
> .subsection-status .status-message,
|
||||
> .unit-status .status-message,
|
||||
> .section-status .status-message-copy,
|
||||
> .subsection-status .status-message-copy,
|
||||
> .unit-status .status-message-copy {
|
||||
color: $color-error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
%outline-item-status {
|
||||
@extend %t-copy-sub2;
|
||||
@extend %t-strong;
|
||||
color: $color-copy-base;
|
||||
|
||||
.icon {
|
||||
@extend %t-icon5;
|
||||
margin-right: ($baseline/4);
|
||||
}
|
||||
}
|
||||
|
||||
// outline: sections
|
||||
.outline-section {
|
||||
@extend %ui-window;
|
||||
@extend %outline-item;
|
||||
@extend %outline-section;
|
||||
|
||||
// header - title
|
||||
.section-title {
|
||||
@extend %t-title5;
|
||||
@extend %t-strong;
|
||||
color: $color-heading-base;
|
||||
}
|
||||
|
||||
// status
|
||||
.section-status {
|
||||
@extend %outline-item-status;
|
||||
}
|
||||
|
||||
// status - release
|
||||
.status-release {
|
||||
@include transition(opacity $tmg-f2 ease-in-out 0s);
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
// status - grading
|
||||
.status-grading {
|
||||
@include transition(opacity $tmg-f2 ease-in-out 0s);
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.status-grading-value {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.status-grading-date {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-left: ($baseline/4);
|
||||
}
|
||||
|
||||
// status - message
|
||||
.status-message {
|
||||
margin-top: ($baseline/2);
|
||||
border-top: 1px solid $gray-l4;
|
||||
padding-top: ($baseline/4);
|
||||
|
||||
.icon {
|
||||
margin-right: ($baseline/4);
|
||||
}
|
||||
}
|
||||
|
||||
.status-message-copy {
|
||||
display: inline-block;
|
||||
color: $color-heading-base;
|
||||
}
|
||||
|
||||
// STATE: hover/active
|
||||
&:hover, &:active {
|
||||
|
||||
// status - release
|
||||
> .section-status .status-release {
|
||||
opacity: 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// outline: subsections
|
||||
.outline-subsection {
|
||||
@extend %outline-item;
|
||||
@extend %outline-subsection;
|
||||
border: 1px solid $gray-l4;
|
||||
border-left: ($baseline/4) solid $color-draft;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
padding: ($baseline*0.75);
|
||||
|
||||
// STATE: hover/active
|
||||
&:hover, &:active {
|
||||
box-shadow: 0 1px 1px $shadow-l2;
|
||||
}
|
||||
|
||||
// STATE: is-collapsed
|
||||
&.is-collapsed {
|
||||
|
||||
}
|
||||
|
||||
// header - title
|
||||
.subsection-title {
|
||||
@extend %t-title6;
|
||||
color: $color-heading-base;
|
||||
}
|
||||
|
||||
// status
|
||||
.subsection-status {
|
||||
@extend %outline-item-status;
|
||||
}
|
||||
|
||||
// STATE: hover/active
|
||||
&:hover, &:active {
|
||||
|
||||
// status - release
|
||||
> .subsection-status .status-release {
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
// status - grading
|
||||
> .subsection-status .status-grading {
|
||||
opacity: 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// outline: units
|
||||
.outline-unit {
|
||||
@extend %outline-item;
|
||||
margin-bottom: ($baseline/2);
|
||||
border: 1px solid $gray-l4;
|
||||
padding: ($baseline/4) ($baseline/2);
|
||||
|
||||
// header - title
|
||||
.unit-title {
|
||||
@extend %t-title7;
|
||||
color: $color-heading-base;
|
||||
}
|
||||
|
||||
.unit-status {
|
||||
@extend %outline-item-status;
|
||||
}
|
||||
|
||||
// STATE: hover/active
|
||||
&:hover, &:active {
|
||||
box-shadow: 0 1px 1px $shadow-l2;
|
||||
|
||||
// status - release
|
||||
.unit-status .status-release {
|
||||
opacity: 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -728,6 +728,70 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
|
||||
// block-level messages and validation
|
||||
.wrapper-message {
|
||||
|
||||
.message {
|
||||
@extend %t-copy-sub1;
|
||||
background-color: $gray-d2;
|
||||
padding: ($baseline/2) ($baseline*.75);
|
||||
color: $white;
|
||||
|
||||
[class^="icon-"] {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
&.information {
|
||||
@extend %t-copy-sub1;
|
||||
background-color: $gray-l5;
|
||||
color: $gray-d2;
|
||||
}
|
||||
|
||||
&.validation {
|
||||
background-color: $gray-d2;
|
||||
color: $white;
|
||||
|
||||
a {
|
||||
color: $blue-l2;
|
||||
}
|
||||
}
|
||||
|
||||
&.has-warnings {
|
||||
border-bottom: 3px solid $orange;
|
||||
|
||||
.icon-warning-sign {
|
||||
margin-right: ($baseline/2);
|
||||
color: $orange;
|
||||
}
|
||||
}
|
||||
|
||||
&.has-errors {
|
||||
border-bottom: 3px solid $red-l2;
|
||||
|
||||
.icon-exclamation-sign {
|
||||
margin-right: ($baseline/2);
|
||||
color: $red-l2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-list {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.message-actions {
|
||||
padding: ($baseline/2) $baseline;
|
||||
background-color: $gray-d1;
|
||||
|
||||
.actions-list {
|
||||
@extend %actions-list;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ====================
|
||||
|
||||
// temporary
|
||||
|
||||
@@ -3,6 +3,23 @@
|
||||
|
||||
// Scale - (6, 7, 8, 9, 10, 11, 12, 14, 16, 18, 21, 24, 36, 48, 60, 72)
|
||||
|
||||
// weights
|
||||
%t-ultrastrong {
|
||||
font-weight: 800;
|
||||
}
|
||||
%t-strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
%t-regular {
|
||||
font-weight: 400;
|
||||
}
|
||||
%t-light {
|
||||
font-weight: 300;
|
||||
}
|
||||
%t-ultralight {
|
||||
font-weight: 200;
|
||||
}
|
||||
|
||||
// headings/titles
|
||||
%t-title {
|
||||
font-family: $f-sans-serif;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user