diff --git a/.jshintrc b/.jshintrc index be4e6a949d..2e88ababdd 100644 --- a/.jshintrc +++ b/.jshintrc @@ -22,12 +22,12 @@ "nonbsp" : true, // Warns about "non-breaking whitespace" characters. "nonew" : true, // Prohibits the use of constructor functions for side-effects. "plusplus" : false, // Prohibits the use of unary increment and decrement operators. - "quotmark" : "single", // Enforces the consistency of quotation marks used throughout your code. It accepts three values: true, "single", and "double". + "quotmark" : false, // Enforces the consistency of quotation marks used throughout your code. It accepts three values: true, "single", and "double". "undef" : true, // Prohibits the use of explicitly undeclared variables. "unused" : true, // Warns when you define and never use your variables. "strict" : true, // Requires all functions to run in ECMAScript 5's strict mode. "trailing" : true, // Makes it an error to leave a trailing whitespace in your code. - "maxlen" : 80, // Lets you set the maximum length of a line. + "maxlen" : 120, // Lets you set the maximum length of a line. //"maxparams" : 4, // Lets you set the max number of formal parameters allowed per function. //"maxdepth" : 4, // Lets you control how nested do you want your blocks to be. //"maxstatements" : 4, // Lets you set the max number of statements allowed per function. @@ -59,7 +59,7 @@ "shadow" : false, // Suppresses warnings about variable shadowing i.e. declaring a variable that had been already declared somewhere in the outer scope. "sub" : false, // Suppresses warnings about using [] notation when it can be expressed in dot notation. "supernew" : false, // Suppresses warnings about "weird" constructions like new function () { ... } and new Object;. - "validthis" : true, // Suppresses warnings about possible strict violations when the code is running in strict mode and you use this in a non-constructor function. + "validthis" : true, // Suppresses warnings about possible strict violations when the code is running in strict mode and you use this in a non-constructor function. "noyield" : false, // Suppresses warnings about generator functions with no yield statement in them. @@ -73,11 +73,11 @@ // The rest should remain `false`. Please see explanation for the "predef" parameter below. "couch" : false, // Defines globals exposed by CouchDB. "dojo" : false, // Defines globals exposed by the Dojo Toolkit - "jquery" : false, // Defines globals exposed by the jQuery JavaScript library. + "jquery" : false, // Defines globals exposed by the jQuery JavaScript library. "mootools" : false, // Defines globals exposed by the MooTools JavaScript framework. "node" : false, // Defines globals available when your code is running inside of the Node runtime environment. "nonstandard" : false, // Defines non-standard but widely adopted globals such as escape and unescape. - "phantom" : false, // Defines globals available when your core is running inside of the PhantomJS runtime environment. + "phantom" : false, // Defines globals available when your core is running inside of the PhantomJS runtime environment. "prototypejs" : false, // Defines globals exposed by the Prototype JavaScript framework. "rhino" : false, // Defines globals available when your code is running inside of the Rhino runtime environment. "worker" : false, // Defines globals available when your code is running inside of a Web Worker. diff --git a/AUTHORS b/AUTHORS index 1556e21249..c170b8459f 100644 --- a/AUTHORS +++ b/AUTHORS @@ -169,4 +169,7 @@ Clinton Blackburn Dennis Jen Filippo Valsorda Ivica Ceraj -Jason Zhu \ No newline at end of file +Jason Zhu +Marceau Cnudde +Braden MacDonald +Jonathan Piacenti diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4ce1290b66..f10ef6215b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,8 @@ 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: Do not allow individual due dates to be earlier than the normal due date. LMS-6563 + Blades: Course teams can turn off Chinese Caching from Studio. BLD-1207 LMS: Instructors can request and see content of previous bulk emails sent in the instructor dashboard. diff --git a/cms/djangoapps/contentstore/features/transcripts.feature b/cms/djangoapps/contentstore/features/transcripts.feature index e2687f2b05..9313b75a1a 100644 --- a/cms/djangoapps/contentstore/features/transcripts.feature +++ b/cms/djangoapps/contentstore/features/transcripts.feature @@ -17,60 +17,6 @@ Feature: CMS Transcripts # one stored on YouTube # t_not_exist - this file does not exist on YouTube; it exists locally - #1 - Scenario: Check input error messages - Given I have created a Video component - And I edit the component - - #User inputs html5 links with equal extension - And I enter a "123.webm" source to field number 1 - And I enter a "456.webm" source to field number 2 - Then I see error message "file_type" - # Currently we are working with 2nd field. It means, that if 2nd field - # contain incorrect value, 1st and 3rd fields should be disabled until - # 2nd field will be filled by correct correct value - And I expect 1, 3 inputs are disabled - When I clear fields - And I expect inputs are enabled - - #User input URL with incorrect format - And I enter a "http://link.c" source to field number 1 - Then I see error message "url_format" - # Currently we are working with 1st field. It means, that if 1st field - # contain incorrect value, 2nd and 3rd fields should be disabled until - # 1st field will be filled by correct correct value - And I expect 2, 3 inputs are disabled - - #User input URL with incorrect format - And I enter a "http://goo.gl/pxxZrg" source to field number 1 - And I enter a "http://goo.gl/pxxZrg" source to field number 2 - Then I see error message "links_duplication" - And I expect 1, 3 inputs are disabled - - And I clear fields - And I expect inputs are enabled - - And I enter a "http://youtu.be/t_not_exist" source to field number 1 - Then I do not see error message - And I expect inputs are enabled - - #2 - Scenario: Testing interaction with test youtube server - Given I have created a Video component with subtitles - And I edit the component - # first part of url will be substituted by mock_youtube_server address - # for t__eq_exist id server will respond with transcripts - And I enter a "http://youtu.be/t__eq_exist" source to field number 1 - Then I see status message "not found on edx" - # t__eq_exist subs locally not presented at this moment - And I see button "import" - - # for t_not_exist id server will respond with 404 - And I enter a "http://youtu.be/t_not_exist" source to field number 1 - Then I see status message "not found" - And I do not see button "import" - And I see button "disabled_download_to_edit" - #3 Scenario: Youtube id only: check "not found" and "import" states Given I have created a Video component with subtitles @@ -93,66 +39,6 @@ Feature: CMS Transcripts And I see button "download_to_edit" And I see value "t__eq_exist" in the field "Default Timed Transcript" - #4 - Scenario: Youtube id only: check "Found" state - Given I have created a Video component with subtitles "t_not_exist" - And I edit the component - - And I enter a "http://youtu.be/t_not_exist" source to field number 1 - Then I see status message "found" - And I see value "t_not_exist" in the field "Default Timed Transcript" - - #5 - Scenario: Youtube id only: check "Found" state when user sets youtube_id with local and server subs and they are equal - - Given I have created a Video component with subtitles "t__eq_exist" - And I edit the component - - And I enter a "http://youtu.be/t__eq_exist" source to field number 1 - And I see status message "found" - And I see value "t__eq_exist" in the field "Default Timed Transcript" - - #6 - Scenario: Youtube id only: check "Found" state when user sets youtube_id with local and server subs and they are not equal - Given I have created a Video component with subtitles "t_neq_exist" - And I edit the component - - And I enter a "http://youtu.be/t_neq_exist" source to field number 1 - And I see status message "replace" - And I see button "replace" - And I click transcript button "replace" - And I see status message "found" - And I see value "t_neq_exist" in the field "Default Timed Transcript" - - #7 - Scenario: html5 source only: check "Not Found" state - Given I have created a Video component - And I edit the component - - And I enter a "t_not_exist.mp4" source to field number 1 - Then I see status message "not found" - And I see value "" in the field "Default Timed Transcript" - - #8 - Scenario: html5 source only: check "Found" state - Given I have created a Video component with subtitles "t_not_exist" - And I edit the component - - And I enter a "t_not_exist.mp4" source to field number 1 - Then I see status message "found" - And I see value "t_not_exist" in the field "Default Timed Transcript" - - #9 - Scenario: User sets youtube_id w/o server but with local subs and one html5 link w/o subs - Given I have created a Video component with subtitles "t_not_exist" - And I edit the component - - And I enter a "http://youtu.be/t_not_exist" source to field number 1 - Then I see status message "found" - - And I enter a "test_video_name.mp4" source to field number 2 - Then I see status message "found" - And I see value "t_not_exist" in the field "Default Timed Transcript" # Disabled 1/29/14 due to flakiness observed in master #10 @@ -170,214 +56,7 @@ Feature: CMS Transcripts # Then I see status message "found" # And I see value "t__eq_exist" in the field "Default Timed Transcript" - #11 - Scenario: User sets youtube_id w/o local but with server subs and one html5 link w/o transcripts w/o import action, then another one html5 link w/o transcripts - Given I have created a Video component - And I edit the component - - And I enter a "http://youtu.be/t__eq_exist" source to field number 1 - Then I see status message "not found on edx" - And I see button "import" - And I see button "upload_new_timed_transcripts" - - And I enter a "t_not_exist.mp4" source to field number 2 - Then I see status message "not found on edx" - And I see button "import" - And I see button "upload_new_timed_transcripts" - - And I enter a "t_not_exist.webm" source to field number 3 - Then I see status message "not found on edx" - And I see button "import" - And I see button "upload_new_timed_transcripts" - - #12 - Scenario: Entering youtube (no importing), and 2 html5 sources without transcripts - "Not Found" - Given I have created a Video component - And I edit the component - And I enter a "http://youtu.be/t_not_exist" source to field number 1 - Then I see status message "not found" - And I see button "disabled_download_to_edit" - And I see button "upload_new_timed_transcripts" - And I enter a "t_not_exist.mp4" source to field number 2 - Then I see status message "not found" - And I see button "upload_new_timed_transcripts" - And I see button "disabled_download_to_edit" - And I enter a "t_not_exist.webm" source to field number 3 - Then I see status message "not found" - And I see button "disabled_download_to_edit" - And I see button "upload_new_timed_transcripts" - - #13 - Scenario: Entering youtube with imported transcripts, and 2 html5 sources without transcripts - "Found" - Given I have created a Video component - And I edit the component - - And I enter a "http://youtu.be/t__eq_exist" source to field number 1 - Then I see status message "not found on edx" - And I see button "import" - And I click transcript button "import" - Then I see status message "found" - And I see button "upload_new_timed_transcripts" - - And I enter a "t_not_exist.mp4" source to field number 2 - Then I see status message "found" - And I see button "download_to_edit" - And I see button "upload_new_timed_transcripts" - - And I enter a "t_not_exist.webm" source to field number 3 - Then I see status message "found" - And I see button "download_to_edit" - And I see button "upload_new_timed_transcripts" - - #14 - Scenario: Entering youtube w/o transcripts - html5 w/o transcripts - html5 with transcripts - Given I have created a Video component with subtitles "t_neq_exist" - And I edit the component - - And I enter a "http://youtu.be/t_not_exist" source to field number 1 - Then I see status message "not found" - And I see button "disabled_download_to_edit" - And I see button "upload_new_timed_transcripts" - - And I enter a "t_not_exist.mp4" source to field number 2 - Then I see status message "not found" - And I see button "disabled_download_to_edit" - And I see button "upload_new_timed_transcripts" - - And I enter a "t_neq_exist.webm" source to field number 3 - Then I see status message "found" - And I see button "download_to_edit" - And I see button "upload_new_timed_transcripts" - - #15 - Scenario: Entering youtube w/o imported transcripts - html5 w/o transcripts w/o import - html5 with transcripts - Given I have created a Video component with subtitles "t_neq_exist" - And I edit the component - - And I enter a "http://youtu.be/t__eq_exist" source to field number 1 - Then I see status message "not found on edx" - And I see button "import" - And I see button "upload_new_timed_transcripts" - - And I enter a "t_not_exist.mp4" source to field number 2 - Then I see status message "not found on edx" - And I see button "import" - And I see button "upload_new_timed_transcripts" - - And I enter a "t_neq_exist.webm" source to field number 3 - Then I see status message "not found on edx" - And I see button "import" - And I see button "upload_new_timed_transcripts" - - #16 - Scenario: Entering youtube w/o imported transcripts - html5 with transcripts - html5 w/o transcripts w/o import - Given I have created a Video component with subtitles "t_neq_exist" - And I edit the component - - And I enter a "http://youtu.be/t__eq_exist" source to field number 1 - Then I see status message "not found on edx" - And I see button "import" - And I see button "upload_new_timed_transcripts" - - And I enter a "t_neq_exist.mp4" source to field number 2 - Then I see status message "not found on edx" - And I see button "import" - And I see button "upload_new_timed_transcripts" - - And I enter a "t_not_exist.webm" source to field number 3 - Then I see status message "not found on edx" - And I see button "import" - And I see button "upload_new_timed_transcripts" - - #17 - Scenario: Entering youtube with imported transcripts - html5 with transcripts - html5 w/o transcripts - Given I have created a Video component with subtitles "t_neq_exist" - And I edit the component - - And I enter a "http://youtu.be/t__eq_exist" source to field number 1 - Then I see status message "not found on edx" - And I see button "import" - And I click transcript button "import" - Then I see status message "found" - And I see button "upload_new_timed_transcripts" - - And I enter a "t_neq_exist.mp4" source to field number 2 - Then I see status message "found" - And I see button "download_to_edit" - And I see button "upload_new_timed_transcripts" - - And I enter a "t_not_exist.webm" source to field number 3 - Then I see status message "found" - And I see button "download_to_edit" - And I see button "upload_new_timed_transcripts" - - #18 - Scenario: Entering youtube with imported transcripts - html5 w/o transcripts - html5 with transcripts - Given I have created a Video component with subtitles "t_neq_exist" - And I edit the component - - And I enter a "http://youtu.be/t__eq_exist" source to field number 1 - Then I see status message "not found on edx" - And I see button "import" - And I click transcript button "import" - Then I see status message "found" - And I see button "upload_new_timed_transcripts" - - And I enter a "t_not_exist.mp4" source to field number 2 - Then I see status message "found" - And I see button "download_to_edit" - And I see button "upload_new_timed_transcripts" - - And I enter a "t_neq_exist.webm" source to field number 3 - Then I see status message "found" - And I see button "download_to_edit" - And I see button "upload_new_timed_transcripts" - - #19 - Scenario: Entering html5 with transcripts - upload - youtube w/o transcripts - Given I have created a Video component with subtitles "t__eq_exist" - And I edit the component - - And I enter a "t__eq_exist.mp4" source to field number 1 - Then I see status message "found" - And I see button "download_to_edit" - And I see button "upload_new_timed_transcripts" - And I upload the transcripts file "uk_transcripts.srt" - Then I see status message "uploaded_successfully" - And I see button "download_to_edit" - And I see button "upload_new_timed_transcripts" - And I see value "t__eq_exist" in the field "Default Timed Transcript" - - And I enter a "http://youtu.be/t_not_exist" source to field number 2 - Then I see status message "found" - And I see button "download_to_edit" - And I see button "upload_new_timed_transcripts" - - And I enter a "uk_transcripts.webm" source to field number 3 - Then I see status message "found" - - #20 - Scenario: Enter 2 HTML5 sources with transcripts, they are not the same, choose - Given I have created a Video component with subtitles "t_not_exist" - And I edit the component - - And I enter a "uk_transcripts.mp4" source to field number 1 - Then I see status message "not found" - And I see button "download_to_edit" - And I see button "upload_new_timed_transcripts" - And I upload the transcripts file "uk_transcripts.srt" - Then I see status message "uploaded_successfully" - And I see value "uk_transcripts" in the field "Default Timed Transcript" - - And I enter a "t_not_exist.webm" source to field number 2 - Then I see status message "replace" - - And I see choose button "uk_transcripts.mp4" number 1 - And I see choose button "t_not_exist.webm" number 2 - And I click transcript button "choose" number 2 - And I see value "uk_transcripts|t_not_exist" in the field "Default Timed Transcript" - - # Flaky test fails occasionally in master. https://edx-wiki.atlassian.net/browse/BLD-927 + # Flaky test fails occasionally in master. https://openedx.atlassian.net/browse/BLD-892 #21 #Scenario: Work with 1 field only: Enter HTML5 source with transcripts - save - > change it to another one HTML5 source w/o #transcripts - click on use existing - > change it to another one HTML5 source w/o transcripts - click on use existing # Given I have created a Video component with subtitles "t_not_exist" @@ -404,87 +83,6 @@ Feature: CMS Transcripts # And I click transcript button "use_existing" # And I see value "video_name_3" in the field "Default Timed Transcript" - #22 - Scenario: Work with 1 field only: Enter HTML5 source with transcripts - save -> change it to another one HTML5 source w/o transcripts - click on use existing -> change it to another one HTML5 source w/o transcripts - do not click on use existing -> change it to another one HTML5 source w/o transcripts - click on use existing - Given I have created a Video component with subtitles "t_not_exist" - And I edit the component - - And I enter a "t_not_exist.mp4" source to field number 1 - Then I see status message "found" - And I see button "download_to_edit" - And I see button "upload_new_timed_transcripts" - And I see value "t_not_exist" in the field "Default Timed Transcript" - - And I save changes - And I edit the component - - And I enter a "video_name_2.mp4" source to field number 1 - Then I see status message "use existing" - And I see button "use_existing" - And I click transcript button "use_existing" - And I see value "video_name_2" in the field "Default Timed Transcript" - - And I enter a "video_name_3.mp4" source to field number 1 - Then I see status message "use existing" - And I see button "use_existing" - - And I enter a "video_name_4.mp4" source to field number 1 - Then I see status message "use existing" - And I see button "use_existing" - And I click transcript button "use_existing" - And I see value "video_name_4" in the field "Default Timed Transcript" - - #23 - Scenario: Work with 2 fields: Enter HTML5 source with transcripts - save -> change it to another one HTML5 source w/o transcripts - do not click on use existing -> add another one HTML5 source w/o transcripts - click on use existing - Given I have created a Video component with subtitles "t_not_exist" - And I edit the component - - And I enter a "t_not_exist.mp4" source to field number 1 - Then I see status message "found" - And I see button "download_to_edit" - And I see button "upload_new_timed_transcripts" - - And I save changes - And I edit the component - - And I enter a "video_name_2.mp4" source to field number 1 - Then I see status message "use existing" - And I see button "use_existing" - - And I enter a "video_name_3.webm" source to field number 2 - Then I see status message "use existing" - And I see button "use_existing" - And I click transcript button "use_existing" - And I see value "video_name_2|video_name_3" in the field "Default Timed Transcript" - - #24 Uploading subtitles with different file name than file - Scenario: File name and name of subs are different - Given I have created a Video component - And I edit the component - - And I enter a "video_name_1.mp4" source to field number 1 - And I see status message "not found" - And I upload the transcripts file "uk_transcripts.srt" - Then I see status message "uploaded_successfully" - And I see value "video_name_1" in the field "Default Timed Transcript" - - And I save changes - Then when I view the video it does show the captions - - And I edit the component - Then I see status message "found" - - #25 - # Video can have filled item.sub, but doesn't have subs file. - # In this case, after changing this video by another one without subs - # `Not found` message should appear ( not `use existing`). - Scenario: Video w/o subs - another video w/o subs - Not found message - Given I have created a Video component - And I edit the component - - And I enter a "video_name_1.mp4" source to field number 1 - Then I see status message "not found" - #26 Scenario: Subtitles are copied for every html5 video source Given I have created a Video component diff --git a/cms/djangoapps/contentstore/features/video_editor.feature b/cms/djangoapps/contentstore/features/video_editor.feature deleted file mode 100644 index 48bdd4a4b4..0000000000 --- a/cms/djangoapps/contentstore/features/video_editor.feature +++ /dev/null @@ -1,241 +0,0 @@ -@shard_1 @requires_stub_youtube -Feature: CMS Video Component Editor - As a course author, I want to be able to create video components - - # 1 - Scenario: User can view Video metadata - Given I have created a Video component - And I edit the component - Then I see the correct video settings and default values - - # 2 - # Safari has trouble saving values on Sauce - @skip_safari - Scenario: User can modify Video display name - Given I have created a Video component - And I edit the component - And I open tab "Advanced" - Then I can modify video display name - And my video display name change is persisted on save - - # 3 - # Sauce Labs cannot delete cookies - @skip_sauce - Scenario: Captions are hidden when "transcript display" is false - Given I have created a Video component with subtitles - And I have set "transcript display" to False - Then when I view the video it does not show the captions - - # 4 - # Sauce Labs cannot delete cookies - @skip_sauce - Scenario: Captions are shown when "transcript display" is true - Given I have created a Video component with subtitles - And I have set "transcript display" to True - Then when I view the video it does show the captions - - # 5 - Scenario: Translations uploading works correctly - Given I have created a Video component - And I edit the component - And I open tab "Advanced" - And I upload transcript file "chinese_transcripts.srt" for "zh" language code - And I save changes - Then when I view the video it does show the captions - And I see "好 各位同学" text in the captions - And I edit the component - And I open tab "Advanced" - And I see translations for "zh" - And I upload transcript file "uk_transcripts.srt" for "uk" language code - And I save changes - Then when I view the video it does show the captions - And I see "好 各位同学" text in the captions - And video language menu has "uk, zh" translations - - # 6 - Scenario: User can upload transcript file with > 1mb size - Given I have created a Video component - And I edit the component - And I open tab "Advanced" - And I upload transcript file "1mb_transcripts.srt" for "uk" language code - And I save changes - Then when I view the video it does show the captions - And I see "Привіт, edX вітає вас." text in the captions - - # 7 - Scenario: Translations downloading works correctly w/ preliminary saving - Given I have created a Video component - And I edit the component - And I open tab "Advanced" - And I upload transcript files: - |lang_code|filename | - |uk |uk_transcripts.srt | - |zh |chinese_transcripts.srt| - And I save changes - And I edit the component - And I open tab "Advanced" - And I see translations for "uk, zh" - And video language menu has "uk, zh" translations - Then I can download transcript for "zh" language code, that contains text "好 各位同学" - And I can download transcript for "uk" language code, that contains text "Привіт, edX вітає вас." - - # 8 - Scenario: Translations downloading works correctly w/o preliminary saving - Given I have created a Video component - And I edit the component - And I open tab "Advanced" - And I upload transcript files: - |lang_code|filename | - |uk |uk_transcripts.srt | - |zh |chinese_transcripts.srt| - Then I can download transcript for "zh" language code, that contains text "好 各位同学" - And I can download transcript for "uk" language code, that contains text "Привіт, edX вітає вас." - - # 9 - Scenario: Translations removing works correctly w/ preliminary saving - Given I have created a Video component - And I edit the component - And I open tab "Advanced" - And I upload transcript files: - |lang_code|filename | - |uk |uk_transcripts.srt | - |zh |chinese_transcripts.srt| - And I save changes - Then when I view the video it does show the captions - And I see "Привіт, edX вітає вас." text in the captions - And video language menu has "uk, zh" translations - And I edit the component - And I open tab "Advanced" - And I see translations for "uk, zh" - Then I remove translation for "uk" language code - And I save changes - Then when I view the video it does show the captions - And I see "好 各位同学" text in the captions - And I edit the component - And I open tab "Advanced" - And I see translations for "zh" - Then I remove translation for "zh" language code - And I save changes - Then when I view the video it does not show the captions - - # 10 - Scenario: Translations removing works correctly w/o preliminary saving - Given I have created a Video component - And I edit the component - And I open tab "Advanced" - And I upload transcript file "uk_transcripts.srt" for "uk" language code - And I see translations for "uk" - Then I remove translation for "uk" language code - And I save changes - Then when I view the video it does not show the captions - - # 11 - Scenario: Translations clearing works correctly w/ preliminary saving - Given I have created a Video component - And I edit the component - And I open tab "Advanced" - And I upload transcript files: - |lang_code|filename | - |uk |uk_transcripts.srt | - |zh |chinese_transcripts.srt| - And I save changes - Then when I view the video it does show the captions - And I see "Привіт, edX вітає вас." text in the captions - And video language menu has "uk, zh" translations - And I edit the component - And I open tab "Advanced" - And I see translations for "uk, zh" - And I click button "Clear" - And I save changes - Then when I view the video it does not show the captions - - # 12 - Scenario: Translations clearing works correctly w/o preliminary saving - Given I have created a Video component - And I edit the component - And I open tab "Advanced" - And I upload transcript files: - |lang_code|filename | - |uk |uk_transcripts.srt | - |zh |chinese_transcripts.srt| - And I click button "Clear" - And I save changes - Then when I view the video it does not show the captions - - # 13 - Scenario: User cannot upload translations in sjson format - Given I have created a Video component - And I edit the component - And I open tab "Advanced" - And I click button "Add" - And I choose "uk" language code - And I try to upload transcript file "uk_transcripts.sjson" - Then I see validation error "Only SRT files can be uploaded. Please select a file ending in .srt to upload." - - # 14 - Scenario: User can easy replace the translation by another one w/ preliminary saving - Given I have created a Video component - And I edit the component - And I open tab "Advanced" - And I upload transcript file "chinese_transcripts.srt" for "zh" language code - And I save changes - Then when I view the video it does show the captions - And I see "好 各位同学" text in the captions - And I edit the component - And I open tab "Advanced" - And I see translations for "zh" - And I replace transcript file for "zh" language code by "uk_transcripts.srt" - And I save changes - Then when I view the video it does show the captions - And I see "Привіт, edX вітає вас." text in the captions - - # 15 - Scenario: User can easy replace the translation by another one w/o preliminary saving - Given I have created a Video component - And I edit the component - And I open tab "Advanced" - And I upload transcript file "chinese_transcripts.srt" for "zh" language code - And I see translations for "zh" - And I replace transcript file for "zh" language code by "uk_transcripts.srt" - And I save changes - Then when I view the video it does show the captions - And I see "Привіт, edX вітає вас." text in the captions - - # 16 - Scenario: Upload "zh" file "A" -> Remove "zh" -> Upload "zh" file "B" - Given I have created a Video component - And I edit the component - And I open tab "Advanced" - And I upload transcript file "chinese_transcripts.srt" for "zh" language code - And I see translations for "zh" - Then I remove translation for "zh" language code - And I upload transcript file "uk_transcripts.srt" for "zh" language code - And I save changes - Then when I view the video it does show the captions - And I see "Привіт, edX вітає вас." text in the captions - - # 17 - Scenario: User cannot select the same language twice - Given I have created a Video component - And I edit the component - And I open tab "Advanced" - And I click button "Add" - And I choose "zh" language code - And I click button "Add" - Then I cannot choose "zh" language code - - # 18 - Scenario: User can see table of content at the first position - Given I have created a Video component - And I edit the component - And I open tab "Advanced" - And I upload transcript files: - |lang_code|filename | - |uk |uk_transcripts.srt | - |table |chinese_transcripts.srt| - And I save changes - Then when I view the video it does show the captions - And I see "好 各位同学" text in the captions - And video language menu has "table, uk" translations - And I see video language with code "table" at position "0" - diff --git a/cms/djangoapps/contentstore/views/tasks.py b/cms/djangoapps/contentstore/tasks.py similarity index 85% rename from cms/djangoapps/contentstore/views/tasks.py rename to cms/djangoapps/contentstore/tasks.py index d0e18e62b8..8b05565eb8 100644 --- a/cms/djangoapps/contentstore/views/tasks.py +++ b/cms/djangoapps/contentstore/tasks.py @@ -4,7 +4,10 @@ This file contains celery tasks for contentstore views from celery.task import task from django.contrib.auth.models import User +import json from xmodule.modulestore.django import modulestore +from xmodule.course_module import CourseFields + from xmodule.modulestore.exceptions import DuplicateCourseError, ItemNotFoundError from course_action_state.models import CourseRerunState from contentstore.utils import initialize_permissions @@ -17,9 +20,10 @@ def rerun_course(source_course_key_string, destination_course_key_string, user_i Reruns a course in a new celery task. """ try: - # deserialize the keys + # deserialize the payload source_course_key = CourseKey.from_string(source_course_key_string) destination_course_key = CourseKey.from_string(destination_course_key_string) + fields = deserialize_fields(fields) if fields else None # use the split modulestore as the store for the rerun course, # as the Mongo modulestore doesn't support multiple runs of the same course. @@ -32,13 +36,11 @@ def rerun_course(source_course_key_string, destination_course_key_string, user_i # update state: Succeeded CourseRerunState.objects.succeeded(course_key=destination_course_key) - return "succeeded" except DuplicateCourseError as exc: # do NOT delete the original course, only update the status CourseRerunState.objects.failed(course_key=destination_course_key, exception=exc) - return "duplicate course" # catch all exceptions so we can update the state and properly cleanup the course. @@ -54,3 +56,10 @@ def rerun_course(source_course_key_string, destination_course_key_string, user_i pass return "exception: " + unicode(exc) + + +def deserialize_fields(json_fields): + fields = json.loads(json_fields) + for field_name, value in fields.iteritems(): + fields[field_name] = getattr(CourseFields, field_name).from_json(value) + return fields diff --git a/cms/djangoapps/contentstore/tests/test_clone_course.py b/cms/djangoapps/contentstore/tests/test_clone_course.py index 71f148820a..25ea5eb6e8 100644 --- a/cms/djangoapps/contentstore/tests/test_clone_course.py +++ b/cms/djangoapps/contentstore/tests/test_clone_course.py @@ -1,9 +1,15 @@ """ Unit tests for cloning a course between the same and different module stores. """ +import json from opaque_keys.edx.locator import CourseLocator -from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore import ModuleStoreEnum, EdxJSONEncoder from contentstore.tests.utils import CourseTestCase +from contentstore.tasks import rerun_course +from contentstore.views.access import has_course_access +from course_action_state.models import CourseRerunState +from course_action_state.managers import CourseRerunUIStateManager +from mock import patch, Mock class CloneCourseTest(CourseTestCase): @@ -28,14 +34,57 @@ class CloneCourseTest(CourseTestCase): # 3. clone course (mongo -> split) with self.store.default_store(ModuleStoreEnum.Type.split): split_course3_id = CourseLocator( - org="edx3", course="split3", run="2013_Fall", branch=ModuleStoreEnum.BranchName.draft + org="edx3", course="split3", run="2013_Fall" ) self.store.clone_course(mongo_course2_id, split_course3_id, self.user.id) self.assertCoursesEqual(mongo_course2_id, split_course3_id) # 4. clone course (split -> split) split_course4_id = CourseLocator( - org="edx4", course="split4", run="2013_Fall", branch=ModuleStoreEnum.BranchName.draft + org="edx4", course="split4", run="2013_Fall" ) self.store.clone_course(split_course3_id, split_course4_id, self.user.id) self.assertCoursesEqual(split_course3_id, split_course4_id) + + def test_rerun_course(self): + """ + Unit tests for :meth: `contentstore.tasks.rerun_course` + """ + mongo_course1_id = self.import_and_populate_course() + + # rerun from mongo into split + split_course3_id = CourseLocator( + org="edx3", course="split3", run="rerun_test" + ) + # Mark the action as initiated + fields = {'display_name': 'rerun'} + CourseRerunState.objects.initiated(mongo_course1_id, split_course3_id, self.user, fields['display_name']) + result = rerun_course.delay(unicode(mongo_course1_id), unicode(split_course3_id), self.user.id, + json.dumps(fields, cls=EdxJSONEncoder)) + self.assertEqual(result.get(), "succeeded") + self.assertTrue(has_course_access(self.user, split_course3_id), "Didn't grant access") + rerun_state = CourseRerunState.objects.find_first(course_key=split_course3_id) + self.assertEqual(rerun_state.state, CourseRerunUIStateManager.State.SUCCEEDED) + + # try creating rerunning again to same name and ensure it generates error + result = rerun_course.delay(unicode(mongo_course1_id), unicode(split_course3_id), self.user.id) + self.assertEqual(result.get(), "duplicate course") + # the below will raise an exception if the record doesn't exist + CourseRerunState.objects.find_first( + course_key=split_course3_id, + state=CourseRerunUIStateManager.State.FAILED + ) + + # try to hit the generic exception catch + with patch('xmodule.modulestore.split_mongo.mongo_connection.MongoConnection.insert_course_index', Mock(side_effect=Exception)): + split_course4_id = CourseLocator(org="edx3", course="split3", run="rerun_fail") + fields = {'display_name': 'total failure'} + CourseRerunState.objects.initiated(split_course3_id, split_course4_id, self.user, fields['display_name']) + result = rerun_course.delay(unicode(split_course3_id), unicode(split_course4_id), self.user.id, + json.dumps(fields, cls=EdxJSONEncoder)) + self.assertIn("exception: ", result.get()) + self.assertIsNone(self.store.get_course(split_course4_id), "Didn't delete course after error") + CourseRerunState.objects.find_first( + course_key=split_course4_id, + state=CourseRerunUIStateManager.State.FAILED + ) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 09e78efc40..946500e299 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -5,6 +5,7 @@ import copy import mock import shutil +import lxml from datetime import timedelta from fs.osfs import OSFS @@ -47,6 +48,9 @@ from student.roles import CourseCreatorRole, CourseInstructorRole from opaque_keys import InvalidKeyError from contentstore.tests.utils import get_url from course_action_state.models import CourseRerunState, CourseRerunUIStateManager + +from unittest import skipIf + from course_action_state.managers import CourseActionStateItemNotFoundError @@ -1197,7 +1201,7 @@ class ContentStoreTest(ContentStoreTestCase): resp = self._show_course_overview(course.id) self.assertContains( resp, - '
'.format( + '
'.format( locator='i4x://MITx/999/course/Robot_Super_Course', course_key='MITx/999/Robot_Super_Course', ), @@ -1580,31 +1584,33 @@ class RerunCourseTest(ContentStoreTestCase): json_resp = parse_json(response) self.assertNotIn('ErrMsg', json_resp) destination_course_key = CourseKey.from_string(json_resp['destination_course_key']) - return destination_course_key - def create_unsucceeded_course_action_html(self, course_key): - """Creates html fragment that is created for the given course_key in the unsucceeded course action section""" - # TODO Update this once the Rerun UI LMS-11011 is implemented. - return '
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, + "visibility_state": visibility_state, + "has_explicit_staff_lock": xblock.fields['visible_to_staff_only'].is_set_on(xblock), "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]), + "has_changes": has_changes, } if data is not None: xblock_info["data"] = data @@ -676,16 +689,31 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F 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 + if visibility_state == VisibilityState.staff_only: + xblock_info["ancestor_has_staff_lock"] = ancestor_has_staff_lock(xblock, parent_xblock) + else: + xblock_info["ancestor_has_staff_lock"] = False + + # Currently, 'edited_by', 'published_by', and 'release_date_from' 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) + if visibility_state == VisibilityState.staff_only: + xblock_info["staff_lock_from"] = _get_staff_lock_from(xblock) + else: + xblock_info["staff_lock_from"] = None + if course_outline: + if xblock_info["has_explicit_staff_lock"]: + xblock_info["staff_only_message"] = True + elif child_info and child_info["children"]: + xblock_info["staff_only_message"] = all([child["staff_only_message"] for child in child_info["children"]]) + else: + xblock_info["staff_only_message"] = False return xblock_info @@ -813,9 +841,21 @@ 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 _xblock_type_and_display_name(find_release_date_source(xblock)) + + +def _get_staff_lock_from(xblock): + """ + Returns a string representation of the section or subsection that sets the xblock's release date + """ + source = find_staff_lock_source(xblock) + return _xblock_type_and_display_name(source) if source else None + + +def _xblock_type_and_display_name(xblock): + """ + Returns a string representation of the xblock's type and display name + """ return _('{section_or_subsection} "{display_name}"').format( - section_or_subsection=xblock_type_display_name(source), - display_name=source.display_name_with_default) + section_or_subsection=xblock_type_display_name(xblock), + display_name=xblock.display_name_with_default) diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index 23e0ba9fa8..0131783ce8 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -9,7 +9,7 @@ from django.http import Http404, HttpResponseBadRequest from django.contrib.auth.decorators import login_required from edxmako.shortcuts import render_to_string -from xmodule_modifiers import replace_static_urls, wrap_xblock, wrap_fragment +from xmodule_modifiers import replace_static_urls, wrap_xblock, wrap_fragment, request_token from xmodule.x_module import PREVIEW_VIEWS, STUDENT_VIEW, AUTHOR_VIEW from xmodule.error_module import ErrorDescriptor from xmodule.exceptions import NotFoundError, ProcessingError @@ -123,7 +123,13 @@ def _preview_module_system(request, descriptor): wrappers = [ # This wrapper wraps the module in the template specified above - partial(wrap_xblock, 'PreviewRuntime', display_name_only=display_name_only, usage_id_serializer=unicode), + partial( + wrap_xblock, + 'PreviewRuntime', + display_name_only=display_name_only, + usage_id_serializer=unicode, + request_token=request_token(request) + ), # This wrapper replaces urls in the output that start with /static # with the correct course-specific url for the static content diff --git a/cms/djangoapps/contentstore/views/tests/test_course_index.py b/cms/djangoapps/contentstore/views/tests/test_course_index.py index e92ef9053d..cf9d99da39 100644 --- a/cms/djangoapps/contentstore/views/tests/test_course_index.py +++ b/cms/djangoapps/contentstore/views/tests/test_course_index.py @@ -3,6 +3,7 @@ Unit tests for getting the list of courses and the course outline. """ import json import lxml +import datetime from contentstore.tests.utils import CourseTestCase from contentstore.utils import reverse_course_url, add_instructor @@ -10,6 +11,8 @@ 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 util.date_utils import get_default_time_display +from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from opaque_keys.edx.locator import CourseLocator @@ -273,3 +276,35 @@ class TestCourseOutline(CourseTestCase): expanded_locators = initial_state['expanded_locators'] self.assertIn(unicode(self.sequential.location), expanded_locators) self.assertIn(unicode(self.vertical.location), expanded_locators) + + def test_start_date_on_page(self): + """ + Verify that the course start date is included on the course outline page. + """ + def _get_release_date(response): + """Return the release date from the course page""" + parsed_html = lxml.html.fromstring(response.content) + return parsed_html.find_class('course-status')[0].find_class('status-release-value')[0].text_content() + + def _assert_settings_link_present(response): + """ + Asserts there's a course settings link on the course page by the course release date. + """ + parsed_html = lxml.html.fromstring(response.content) + settings_link = parsed_html.find_class('course-status')[0].find_class('action-edit')[0].find('a') + self.assertIsNotNone(settings_link) + self.assertEqual(settings_link.get('href'), reverse_course_url('settings_handler', self.course.id)) + + outline_url = reverse_course_url('course_handler', self.course.id) + response = self.client.get(outline_url, {}, HTTP_ACCEPT='text/html') + + # A course with the default release date should display as "Unscheduled" + self.assertEqual(_get_release_date(response), 'Unscheduled') + _assert_settings_link_present(response) + + self.course.start = datetime.datetime(2014, 1, 1) + modulestore().update_item(self.course, ModuleStoreEnum.UserID.test) + response = self.client.get(outline_url, {}, HTTP_ACCEPT='text/html') + + self.assertEqual(_get_release_date(response), get_default_time_display(self.course.start)) + _assert_settings_link_present(response) diff --git a/cms/djangoapps/contentstore/views/tests/test_group_configurations.py b/cms/djangoapps/contentstore/views/tests/test_group_configurations.py index 2f413871e7..4b9ddde8d5 100644 --- a/cms/djangoapps/contentstore/views/tests/test_group_configurations.py +++ b/cms/djangoapps/contentstore/views/tests/test_group_configurations.py @@ -7,7 +7,6 @@ from contentstore.utils import reverse_course_url, reverse_usage_url from contentstore.views.component import SPLIT_TEST_COMPONENT_TYPE from contentstore.views.course import GroupConfiguration from contentstore.tests.utils import CourseTestCase -from util.testing import UrlResetMixin from xmodule.partitions.partitions import Group, UserPartition from xmodule.modulestore.tests.factories import ItemFactory from xmodule.split_test_module import ValidationMessage, ValidationMessageType @@ -121,13 +120,11 @@ class GroupConfigurationsBaseTestCase(object): {u'name': u'Group B'}, ], }, - # must have at least two groups + # must have at least one group { u'name': u'Test name', u'description': u'Test description', - u'groups': [ - {u'name': u'Group A'}, - ], + u'groups': [], }, # an empty json {}, @@ -167,11 +164,10 @@ class GroupConfigurationsBaseTestCase(object): # pylint: disable=no-member -class GroupConfigurationsListHandlerTestCase(UrlResetMixin, CourseTestCase, GroupConfigurationsBaseTestCase, HelperMethods): +class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurationsBaseTestCase, HelperMethods): """ Test cases for group_configurations_list_handler. """ - @patch.dict("django.conf.settings.FEATURES", {"ENABLE_GROUP_CONFIGURATIONS": True}) def setUp(self): """ Set up GroupConfigurationsListHandlerTestCase. @@ -263,14 +259,13 @@ class GroupConfigurationsListHandlerTestCase(UrlResetMixin, CourseTestCase, Grou # pylint: disable=no-member -class GroupConfigurationsDetailHandlerTestCase(UrlResetMixin, CourseTestCase, GroupConfigurationsBaseTestCase, HelperMethods): +class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfigurationsBaseTestCase, HelperMethods): """ Test cases for group_configurations_detail_handler. """ ID = 0 - @patch.dict("django.conf.settings.FEATURES", {"ENABLE_GROUP_CONFIGURATIONS": True}) def setUp(self): """ Set up GroupConfigurationsDetailHandlerTestCase. @@ -422,12 +417,11 @@ class GroupConfigurationsDetailHandlerTestCase(UrlResetMixin, CourseTestCase, Gr # pylint: disable=no-member -class GroupConfigurationsUsageInfoTestCase(UrlResetMixin, CourseTestCase, HelperMethods): +class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods): """ Tests for usage information of configurations. """ - @patch.dict("django.conf.settings.FEATURES", {"ENABLE_GROUP_CONFIGURATIONS": True}) def setUp(self): super(GroupConfigurationsUsageInfoTestCase, self).setUp() @@ -544,7 +538,6 @@ class GroupConfigurationsValidationTestCase(CourseTestCase, HelperMethods): """ Tests for validation in Group Configurations. """ - @patch.dict("django.conf.settings.FEATURES", {"ENABLE_GROUP_CONFIGURATIONS": True}) def setUp(self): super(GroupConfigurationsValidationTestCase, self).setUp() diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py index 9645ccbbf0..c613ab59d6 100644 --- a/cms/djangoapps/contentstore/views/tests/test_item.py +++ b/cms/djangoapps/contentstore/views/tests/test_item.py @@ -1,8 +1,9 @@ """Tests for items views.""" - +import os import json from datetime import datetime, timedelta import ddt +from unittest import skipUnless from mock import patch from pytz import UTC @@ -12,14 +13,14 @@ from django.http import Http404 from django.test import TestCase from django.test.client import RequestFactory from django.core.urlresolvers import reverse -from contentstore.utils import reverse_usage_url +from contentstore.utils import reverse_usage_url, reverse_course_url from contentstore.views.preview import StudioUserService from contentstore.views.component import ( component_handler, get_component_templates ) -from contentstore.views.item import create_xblock_info, ALWAYS, VisibilityState +from contentstore.views.item import create_xblock_info, ALWAYS, VisibilityState, _xblock_type_and_display_name from contentstore.tests.utils import CourseTestCase from student.tests.factories import UserFactory from xmodule.capa_module import CapaDescriptor @@ -179,6 +180,53 @@ class GetItem(ItemTest): self.assertIn('Zooming', html) + def test_split_test_edited(self): + """ + Test that rename of a group changes display name of child vertical. + """ + self.course.user_partitions = [UserPartition( + 0, 'first_partition', 'First Partition', + [Group("0", 'alpha'), Group("1", 'beta')] + )] + self.store.update_item(self.course, self.user.id) + root_usage_key = self._create_vertical() + resp = self.create_xblock(category='split_test', parent_usage_key=root_usage_key) + split_test_usage_key = self.response_usage_key(resp) + self.client.ajax_post( + reverse_usage_url("xblock_handler", split_test_usage_key), + data={'metadata': {'user_partition_id': str(0)}} + ) + html, __ = self._get_container_preview(split_test_usage_key) + self.assertIn('alpha', html) + self.assertIn('beta', html) + + # Rename groups in group configuration + GROUP_CONFIGURATION_JSON = { + u'id': 0, + u'name': u'first_partition', + u'description': u'First Partition', + u'version': 1, + u'groups': [ + {u'id': 0, u'name': u'New_NAME_A', u'version': 1}, + {u'id': 1, u'name': u'New_NAME_B', u'version': 1}, + ], + } + + response = self.client.put( + reverse_course_url('group_configurations_detail_handler', self.course.id, kwargs={'group_configuration_id': 0}), + data=json.dumps(GROUP_CONFIGURATION_JSON), + content_type="application/json", + HTTP_ACCEPT="application/json", + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + self.assertEqual(response.status_code, 201) + html, __ = self._get_container_preview(split_test_usage_key) + self.assertNotIn('alpha', html) + self.assertNotIn('beta', html) + self.assertIn('New_NAME_A', html) + self.assertIn('New_NAME_B', html) + + class DeleteItem(ItemTest): """Tests for '/xblock' DELETE url.""" def test_delete_static_page(self): @@ -259,7 +307,8 @@ class TestCreateItem(ItemTest): # Check that its name is not None new_tab = self.get_item_from_modulestore(usage_key) - self.assertEquals(new_tab.display_name, 'Empty') + self.assertEquals(new_tab.display_name, 'Empty') + class TestDuplicateItem(ItemTest): """ @@ -620,6 +669,20 @@ class TestEditItem(ItemTest): ) self.assertEqual(published.display_name, new_display_name_2) + def test_direct_only_categories_not_republished(self): + """Verify that republish is ignored for items in DIRECT_ONLY_CATEGORIES""" + # Create a vertical child with published and unpublished versions. + # If the parent sequential is not re-published, then the child problem should also not be re-published. + resp = self.create_xblock(parent_usage_key=self.seq_usage_key, display_name='vertical', category='vertical') + vertical_usage_key = self.response_usage_key(resp) + vertical_update_url = reverse_usage_url('xblock_handler', vertical_usage_key) + self.client.ajax_post(vertical_update_url, data={'publish': 'make_public'}) + self.client.ajax_post(vertical_update_url, data={'metadata': {'display_name': 'New Display Name'}}) + + self._verify_published_with_draft(self.seq_usage_key) + self.client.ajax_post(self.seq_update_url, data={'publish': 'republish'}) + self._verify_published_with_draft(self.seq_usage_key) + def _make_draft_content_different_from_published(self): """ Helper method to create different draft and published versions of a problem. @@ -806,8 +869,8 @@ class TestEditSplitModule(ItemTest): vertical_1 = self.get_item_from_modulestore(split_test.children[1], verify_is_draft=True) self.assertEqual("vertical", vertical_0.category) self.assertEqual("vertical", vertical_1.category) - self.assertEqual("alpha", vertical_0.display_name) - self.assertEqual("beta", vertical_1.display_name) + self.assertEqual("Group ID 0", vertical_0.display_name) + self.assertEqual("Group ID 1", vertical_1.display_name) # Verify that the group_id_to_child mapping is correct. self.assertEqual(2, len(split_test.group_id_to_child)) @@ -932,7 +995,7 @@ class TestEditSplitModule(ItemTest): # group_id_to_child and children have not changed yet. split_test = self._assert_children(2) - group_id_to_child = split_test.group_id_to_child + group_id_to_child = split_test.group_id_to_child.copy() self.assertEqual(2, len(group_id_to_child)) # Test environment and Studio use different module systems @@ -1274,10 +1337,13 @@ class TestXBlockPublishingInfo(ItemTest): """ Creates a child xblock for the given parent. """ - return ItemFactory.create( + child = 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 + user_id=self.user.id, publish_item=publish_item ) + if staff_only: + self._enable_staff_only(child.location) + return child def _get_child_xblock_info(self, xblock_info, index): """ @@ -1297,6 +1363,17 @@ class TestXBlockPublishingInfo(ItemTest): include_children_predicate=ALWAYS, ) + def _get_xblock_outline_info(self, location): + """ + Returns the xblock info for the specified location as neeeded for the course outline page. + """ + return create_xblock_info( + modulestore().get_item(location), + include_child_info=True, + include_children_predicate=ALWAYS, + course_outline=True + ) + def _set_release_date(self, location, start): """ Sets the release date for the specified xblock. @@ -1305,12 +1382,12 @@ class TestXBlockPublishingInfo(ItemTest): xblock.start = start self.store.update_item(xblock, self.user.id) - def _set_staff_only(self, location, staff_only): + def _enable_staff_only(self, location): """ - Sets staff only for the specified xblock. + Enables staff only for the specified xblock. """ xblock = modulestore().get_item(location) - xblock.visible_to_staff_only = staff_only + xblock.visible_to_staff_only = True self.store.update_item(xblock, self.user.id) def _set_display_name(self, location, display_name): @@ -1321,22 +1398,50 @@ class TestXBlockPublishingInfo(ItemTest): xblock.display_name = display_name self.store.update_item(xblock, self.user.id) - def _verify_visibility_state(self, xblock_info, expected_state, path=None): + def _verify_xblock_info_state(self, xblock_info, xblock_info_field, expected_state, path=None, should_equal=True): """ - Verify the publish state of an item in the xblock_info. If no path is provided - then the root item will be verified. + Verify the state of an xblock_info field. If no path is provided then the root item will be verified. + If should_equal is True, assert that the current state matches the expected state, otherwise assert that they + do not match. """ 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) + self._verify_xblock_info_state(direct_child_xblock_info, xblock_info_field, expected_state, remaining_path, should_equal) else: - self.assertEqual(xblock_info['visibility_state'], expected_state) + if should_equal: + self.assertEqual(xblock_info[xblock_info_field], expected_state) + else: + self.assertNotEqual(xblock_info[xblock_info_field], expected_state) + + def _verify_has_staff_only_message(self, xblock_info, expected_state, path=None): + """ + Verify the staff_only_message field of xblock_info. + """ + self._verify_xblock_info_state(xblock_info, 'staff_only_message', expected_state, path) + + def _verify_visibility_state(self, xblock_info, expected_state, path=None, should_equal=True): + """ + Verify the publish state of an item in the xblock_info. + """ + self._verify_xblock_info_state(xblock_info, 'visibility_state', expected_state, path, should_equal) + + def _verify_explicit_staff_lock_state(self, xblock_info, expected_state, path=None, should_equal=True): + """ + Verify the explicit staff lock state of an item in the xblock_info. + """ + self._verify_xblock_info_state(xblock_info, 'has_explicit_staff_lock', expected_state, path, should_equal) + + def _verify_staff_lock_from_state(self, xblock_info, expected_state, path=None, should_equal=True): + """ + Verify the staff_lock_from state of an item in the xblock_info. + """ + self._verify_xblock_info_state(xblock_info, 'staff_lock_from', expected_state, path, should_equal) 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) + self._verify_visibility_state(xblock_info, VisibilityState.unscheduled) def test_empty_sequential(self): chapter = self._create_child(self.course, 'chapter', "Test Chapter") @@ -1416,16 +1521,83 @@ class TestXBlockPublishingInfo(ItemTest): # 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") + def test_staff_only_section(self): + """ + Tests that an explicitly staff-locked section and all of its children are visible to staff only. + """ + chapter = self._create_child(self.course, 'chapter', "Test Chapter", staff_only=True) sequential = self._create_child(chapter, 'sequential', "Test Sequential") - unit = self._create_child(sequential, 'vertical', "Published Unit") - self._set_staff_only(unit.location, True) + self._create_child(sequential, 'vertical', "Unit") 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) + self._verify_explicit_staff_lock_state(xblock_info, True) + self._verify_explicit_staff_lock_state(xblock_info, False, path=self.FIRST_SUBSECTION_PATH) + self._verify_explicit_staff_lock_state(xblock_info, False, path=self.FIRST_UNIT_PATH) + + self._verify_staff_lock_from_state(xblock_info, _xblock_type_and_display_name(chapter), path=self.FIRST_UNIT_PATH) + + def test_no_staff_only_section(self): + """ + Tests that a section with a staff-locked subsection and a visible subsection is not staff locked itself. + """ + chapter = self._create_child(self.course, 'chapter', "Test Chapter") + self._create_child(chapter, 'sequential', "Test Visible Sequential") + self._create_child(chapter, 'sequential', "Test Staff Locked Sequential", staff_only=True) + xblock_info = self._get_xblock_info(chapter.location) + self._verify_visibility_state(xblock_info, VisibilityState.staff_only, should_equal=False) + self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=[0], should_equal=False) + self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=[1]) + + def test_staff_only_subsection(self): + """ + Tests that an explicitly staff-locked subsection and all of its children are visible to staff only. + In this case the parent section is also visible to staff only because all of its children are staff only. + """ + chapter = self._create_child(self.course, 'chapter', "Test Chapter") + sequential = self._create_child(chapter, 'sequential', "Test Sequential", staff_only=True) + self._create_child(sequential, 'vertical', "Unit") + 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) + + self._verify_explicit_staff_lock_state(xblock_info, False) + self._verify_explicit_staff_lock_state(xblock_info, True, path=self.FIRST_SUBSECTION_PATH) + self._verify_explicit_staff_lock_state(xblock_info, False, path=self.FIRST_UNIT_PATH) + + self._verify_staff_lock_from_state(xblock_info, _xblock_type_and_display_name(sequential), path=self.FIRST_UNIT_PATH) + + def test_no_staff_only_subsection(self): + """ + Tests that a subsection with a staff-locked unit and a visible unit is not staff locked itself. + """ + chapter = self._create_child(self.course, 'chapter', "Test Chapter") + sequential = self._create_child(chapter, 'sequential', "Test Sequential") + self._create_child(sequential, 'vertical', "Unit") + self._create_child(sequential, 'vertical', "Locked Unit", staff_only=True) + xblock_info = self._get_xblock_info(chapter.location) + self._verify_visibility_state(xblock_info, VisibilityState.staff_only, self.FIRST_SUBSECTION_PATH, should_equal=False) + self._verify_visibility_state(xblock_info, VisibilityState.staff_only, self.FIRST_UNIT_PATH, should_equal=False) + self._verify_visibility_state(xblock_info, VisibilityState.staff_only, self.SECOND_UNIT_PATH) + + def test_staff_only_unit(self): + chapter = self._create_child(self.course, 'chapter', "Test Chapter") + sequential = self._create_child(chapter, 'sequential', "Test Sequential") + unit = self._create_child(sequential, 'vertical', "Unit", staff_only=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) + + self._verify_explicit_staff_lock_state(xblock_info, False) + self._verify_explicit_staff_lock_state(xblock_info, False, path=self.FIRST_SUBSECTION_PATH) + self._verify_explicit_staff_lock_state(xblock_info, True, path=self.FIRST_UNIT_PATH) + + self._verify_staff_lock_from_state(xblock_info, _xblock_type_and_display_name(unit), 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") @@ -1450,3 +1622,27 @@ class TestXBlockPublishingInfo(ItemTest): 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_locked_section_staff_only_message(self): + """ + Tests that a locked section has a staff only message and its descendants do not. + """ + chapter = self._create_child(self.course, 'chapter', "Test Chapter", staff_only=True) + sequential = self._create_child(chapter, 'sequential', "Test Sequential") + self._create_child(sequential, 'vertical', "Unit") + xblock_info = self._get_xblock_outline_info(chapter.location) + self._verify_has_staff_only_message(xblock_info, True) + self._verify_has_staff_only_message(xblock_info, False, path=self.FIRST_SUBSECTION_PATH) + self._verify_has_staff_only_message(xblock_info, False, path=self.FIRST_UNIT_PATH) + + def test_locked_unit_staff_only_message(self): + """ + Tests that a lone locked unit has a staff only message along with its ancestors. + """ + chapter = self._create_child(self.course, 'chapter', "Test Chapter") + sequential = self._create_child(chapter, 'sequential', "Test Sequential") + self._create_child(sequential, 'vertical', "Unit", staff_only=True) + xblock_info = self._get_xblock_outline_info(chapter.location) + self._verify_has_staff_only_message(xblock_info, True) + self._verify_has_staff_only_message(xblock_info, True, path=self.FIRST_SUBSECTION_PATH) + self._verify_has_staff_only_message(xblock_info, True, path=self.FIRST_UNIT_PATH) diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py index e466020c10..6c61cd8419 100644 --- a/cms/djangoapps/models/settings/course_details.py +++ b/cms/djangoapps/models/settings/course_details.py @@ -86,14 +86,18 @@ class CourseDetails(object): temploc = course_key.make_usage_key('about', about_key) store = modulestore() if data is None: - store.delete_item(temploc, user.id) + try: + store.delete_item(temploc, user.id) + # Ignore an attempt to delete an item that doesn't exist + except ValueError: + pass else: try: about_item = store.get_item(temploc) except ItemNotFoundError: about_item = store.create_xblock(course.runtime, course.id, 'about', about_key) about_item.data = data - store.update_item(about_item, user.id) + store.update_item(about_item, user.id, allow_not_found=True) @classmethod def update_from_json(cls, course_key, jsondict, user): diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index f716f717bc..09d87a2b21 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -24,6 +24,7 @@ class CourseMetadata(object): 'graded', 'hide_from_toc', 'pdf_textbooks', + 'user_partitions', 'name', # from xblock 'tags', # from xblock 'visible_to_staff_only' diff --git a/cms/envs/acceptance.py b/cms/envs/acceptance.py index 7264394877..d2593d16a3 100644 --- a/cms/envs/acceptance.py +++ b/cms/envs/acceptance.py @@ -18,6 +18,9 @@ DEBUG = True import logging logging.basicConfig(filename=TEST_ROOT / "log" / "cms_acceptance.log", level=logging.ERROR) +# set root logger level +logging.getLogger().setLevel(logging.ERROR) + import os diff --git a/cms/envs/aws.py b/cms/envs/aws.py index e3b0924c8d..1027612035 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -243,6 +243,7 @@ if 'DATADOG_API' in AUTH_TOKENS: DATADOG['api_key'] = AUTH_TOKENS['DATADOG_API'] # Celery Broker +CELERY_ALWAYS_EAGER = ENV_TOKENS.get("CELERY_ALWAYS_EAGER", False) CELERY_BROKER_TRANSPORT = ENV_TOKENS.get("CELERY_BROKER_TRANSPORT", "") CELERY_BROKER_HOSTNAME = ENV_TOKENS.get("CELERY_BROKER_HOSTNAME", "") CELERY_BROKER_VHOST = ENV_TOKENS.get("CELERY_BROKER_VHOST", "") diff --git a/cms/envs/bok_choy.env.json b/cms/envs/bok_choy.env.json index 7580de1f47..204515802c 100644 --- a/cms/envs/bok_choy.env.json +++ b/cms/envs/bok_choy.env.json @@ -45,6 +45,7 @@ ] } }, + "CELERY_ALWAYS_EAGER": true, "CELERY_BROKER_HOSTNAME": "localhost", "CELERY_BROKER_TRANSPORT": "amqp", "CERT_QUEUE": "certificates", @@ -70,7 +71,8 @@ "PREVIEW_LMS_BASE": "localhost:8003", "SUBDOMAIN_BRANDING": false, "SUBDOMAIN_COURSE_LISTINGS": false, - "ALLOW_ALL_ADVANCED_COMPONENTS": true + "ALLOW_ALL_ADVANCED_COMPONENTS": true, + "ALLOW_COURSE_RERUNS": true }, "FEEDBACK_SUBMISSION_EMAIL": "", "GITHUB_REPO_ROOT": "** OVERRIDDEN **", diff --git a/cms/envs/common.py b/cms/envs/common.py index ee4a1969a9..5f8ebfdb17 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -104,9 +104,6 @@ FEATURES = { # Turn off Advanced Security by default 'ADVANCED_SECURITY': False, - # Toggles Group Configuration editing functionality - 'ENABLE_GROUP_CONFIGURATIONS': os.environ.get('FEATURE_GROUP_CONFIGURATIONS'), - # Modulestore to use for new courses 'DEFAULT_STORE_FOR_NEW_COURSE': 'mongo', } diff --git a/cms/envs/dev.py b/cms/envs/dev.py index 3a6e06f4a0..e4f0030fb0 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -165,6 +165,7 @@ DEBUG_TOOLBAR_MONGO_STACKTRACES = False # Enable URL that shows information about the status of variuous services FEATURES['ENABLE_SERVICE_STATUS'] = True +FEATURES['ALLOW_COURSE_RERUNS'] = True ############################# SEGMENT-IO ################################## diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py index 145c1d8480..05986e8b3d 100644 --- a/cms/envs/devstack.py +++ b/cms/envs/devstack.py @@ -40,6 +40,10 @@ FEATURES['ALLOW_ALL_ADVANCED_COMPONENTS'] = True # By default don't use a worker, execute tasks as if they were local functions CELERY_ALWAYS_EAGER = True +################################ COURSE RERUNS ################################ + +FEATURES['ALLOW_COURSE_RERUNS'] = True + ################################ DEBUG TOOLBAR ################################ INSTALLED_APPS += ('debug_toolbar', 'debug_toolbar_mongo') MIDDLEWARE_CLASSES += ('debug_toolbar.middleware.DebugToolbarMiddleware',) diff --git a/cms/envs/test.py b/cms/envs/test.py index ba728025a9..94e6889db5 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -21,6 +21,12 @@ from uuid import uuid4 # import settings from LMS for consistent behavior with CMS from lms.envs.test import (WIKI_ENABLED, PLATFORM_NAME, SITE_NAME) +# mongo connection settings +MONGO_PORT_NUM = int(os.environ.get('EDXAPP_TEST_MONGO_PORT', '27017')) +MONGO_HOST = os.environ.get('EDXAPP_TEST_MONGO_HOST', 'localhost') + +THIS_UUID = uuid4().hex[:5] + # Nose Test Runner TEST_RUNNER = 'django_nose.NoseTestSuiteRunner' @@ -87,15 +93,18 @@ update_module_store_settings( }, doc_store_settings={ 'db': 'test_xmodule', - 'collection': 'test_modulestore{0}'.format(uuid4().hex[:5]), + 'host': MONGO_HOST, + 'port': MONGO_PORT_NUM, + 'collection': 'test_modulestore{0}'.format(THIS_UUID), }, ) CONTENTSTORE = { 'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore', 'DOC_STORE_CONFIG': { - 'host': 'localhost', + 'host': MONGO_HOST, 'db': 'test_xcontent', + 'port': MONGO_PORT_NUM, 'collection': 'dont_trip', }, # allow for additional options that can be keyed on a name, e.g. 'trashcan' diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee index 2854eabe34..9bd64c5a2a 100644 --- a/cms/static/coffee/spec/main.coffee +++ b/cms/static/coffee/spec/main.coffee @@ -233,6 +233,8 @@ define([ "js/spec/views/pages/container_subviews_spec", "js/spec/views/pages/group_configurations_spec", "js/spec/views/pages/course_outline_spec", + "js/spec/views/pages/course_rerun_spec", + "js/spec/views/pages/index_spec", "js/spec/views/modals/base_modal_spec", "js/spec/views/modals/edit_xblock_spec", diff --git a/cms/static/js/index.js b/cms/static/js/index.js index 60469c66c7..c984310197 100644 --- a/cms/static/js/index.js +++ b/cms/static/js/index.js @@ -1,21 +1,29 @@ -require(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape"], - function (domReady, $, _, CancelOnEscape) { +define(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape", "js/views/utils/create_course_utils", + "js/views/utils/view_utils"], + function (domReady, $, _, CancelOnEscape, CreateCourseUtilsFactory, ViewUtils) { + var CreateCourseUtils = CreateCourseUtilsFactory({ + name: '.new-course-name', + org: '.new-course-org', + number: '.new-course-number', + run: '.new-course-run', + save: '.new-course-save', + errorWrapper: '.wrap-error', + errorMessage: '#course_creation_error', + tipError: 'span.tip-error', + error: '.error', + allowUnicode: '.allow-unicode-course-id' + }, { + shown: 'is-shown', + showing: 'is-showing', + hiding: 'is-hiding', + disabled: 'is-disabled', + error: 'error' + }); + var saveNewCourse = function (e) { e.preventDefault(); - // One final check for empty values - var errors = _.reduce( - ['.new-course-name', '.new-course-org', '.new-course-number', '.new-course-run'], - function (acc, ele) { - var $ele = $(ele); - var error = validateRequiredField($ele.val()); - setNewCourseFieldInErr($ele.parent('li'), error); - return error ? true : acc; - }, - false - ); - - if (errors) { + if (CreateCourseUtils.hasInvalidRequiredFields()) { return; } @@ -25,29 +33,19 @@ require(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape"], var number = $newCourseForm.find('.new-course-number').val(); var run = $newCourseForm.find('.new-course-run').val(); - analytics.track('Created a Course', { - 'org': org, - 'number': number, - 'display_name': display_name, - 'run': run - }); + course_info = { + org: org, + number: number, + display_name: display_name, + run: run + }; - $.postJSON('/course/', { - 'org': org, - 'number': number, - 'display_name': display_name, - 'run': run - }, - function (data) { - if (data.url !== undefined) { - window.location = data.url; - } else if (data.ErrMsg !== undefined) { - $('.wrap-error').addClass('is-shown'); - $('#course_creation_error').html('

' + data.ErrMsg + '

'); - $('.new-course-save').addClass('is-disabled'); - } - } - ); + analytics.track('Created a Course', course_info); + CreateCourseUtils.createCourse(course_info, function (errorMessage) { + $('.wrap-error').addClass('is-shown'); + $('#course_creation_error').html('

' + errorMessage + '

'); + $('.new-course-save').addClass('is-disabled'); + }); }; var cancelNewCourse = function (e) { @@ -78,91 +76,20 @@ require(["domReady", "jquery", "underscore", "js/utils/cancel_on_escape"], $cancelButton.bind('click', cancelNewCourse); CancelOnEscape($cancelButton); - // Check that a course (org, number, run) doesn't use any special characters - var validateCourseItemEncoding = function (item) { - var required = validateRequiredField(item); - if (required) { - return required; - } - if ($('.allow-unicode-course-id').val() === 'True'){ - if (/\s/g.test(item)) { - return gettext('Please do not use any spaces in this field.'); - } - } - else{ - if (item !== encodeURIComponent(item)) { - return gettext('Please do not use any spaces or special characters in this field.'); - } - } - return ''; - }; - - // Ensure that org/course_num/run < 65 chars. - var validateTotalCourseItemsLength = function () { - var totalLength = _.reduce( - ['.new-course-org', '.new-course-number', '.new-course-run'], - function (sum, ele) { - return sum + $(ele).val().length; - }, 0 - ); - if (totalLength > 65) { - $('.wrap-error').addClass('is-shown'); - $('#course_creation_error').html('

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

'); - $('.new-course-save').addClass('is-disabled'); - } - else { - $('.wrap-error').removeClass('is-shown'); - } - }; - - // Handle validation asynchronously - _.each( - ['.new-course-org', '.new-course-number', '.new-course-run'], - function (ele) { - var $ele = $(ele); - $ele.on('keyup', function (event) { - // Don't bother showing "required field" error when - // the user tabs into a new field; this is distracting - // and unnecessary - if (event.keyCode === 9) { - return; - } - var error = validateCourseItemEncoding($ele.val()); - setNewCourseFieldInErr($ele.parent('li'), error); - validateTotalCourseItemsLength(); - }); - } - ); - var $name = $('.new-course-name'); - $name.on('keyup', function () { - var error = validateRequiredField($name.val()); - setNewCourseFieldInErr($name.parent('li'), error); - validateTotalCourseItemsLength(); - }); + CreateCourseUtils.configureHandlers(); }; - var validateRequiredField = function (msg) { - return msg.length === 0 ? gettext('Required field.') : ''; - }; - - var setNewCourseFieldInErr = function (el, msg) { - if(msg) { - el.addClass('error'); - el.children('span.tip-error').addClass('is-showing').removeClass('is-hiding').text(msg); - $('.new-course-save').addClass('is-disabled'); - } - else { - el.removeClass('error'); - el.children('span.tip-error').addClass('is-hiding').removeClass('is-showing'); - // One "error" div is always present, but hidden or shown - if($('.error').length === 1) { - $('.new-course-save').removeClass('is-disabled'); - } - } - }; - - - domReady(function () { + var onReady = function () { $('.new-course-button').bind('click', addNewCourse); - }); + $('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function () { + ViewUtils.reload(); + })); + $('.action-reload').bind('click', ViewUtils.reload); + }; + + domReady(onReady); + + return { + onReady: onReady + }; }); diff --git a/cms/static/js/models/group_configuration.js b/cms/static/js/models/group_configuration.js index f2e42a6531..95c97ee3d5 100644 --- a/cms/static/js/models/group_configuration.js +++ b/cms/static/js/models/group_configuration.js @@ -80,14 +80,14 @@ function(Backbone, _, str, gettext, GroupModel, GroupCollection) { validate: function(attrs) { if (!_.str.trim(attrs.name)) { return { - message: gettext('Group Configuration name is required'), + message: gettext('Group Configuration name is required.'), attributes: {name: true} }; } - if (attrs.groups.length < 2) { + if (attrs.groups.length < 1) { return { - message: gettext('There must be at least two groups'), + message: gettext('There must be at least one group.'), attributes: { groups: true } }; } else { @@ -100,7 +100,7 @@ function(Backbone, _, str, gettext, GroupModel, GroupCollection) { }); if (!_.isEmpty(invalidGroups)) { return { - message: gettext('All groups must have a name'), + message: gettext('All groups must have a name.'), attributes: { groups: invalidGroups } }; } diff --git a/cms/static/js/models/xblock_info.js b/cms/static/js/models/xblock_info.js index 2bb2cd49e7..d2324313b5 100644 --- a/cms/static/js/models/xblock_info.js +++ b/cms/static/js/models/xblock_info.js @@ -59,7 +59,7 @@ function(Backbone, _, str, ModuleUtils) { */ "visibility_state": null, /** - * True iff the release date of the xblock is in the past. + * True if the release date of the xblock is in the past. */ 'released_to_students': null, /** @@ -102,7 +102,25 @@ function(Backbone, _, str, ModuleUtils) { /** * The same as `due_date` but as an ISO-formatted date string. */ - 'due': null + 'due': null, + /** + * True iff this xblock is explicitly staff locked. + */ + 'has_explicit_staff_lock': null, + /** + * True iff this any of this xblock's ancestors are staff locked. + */ + 'ancestor_has_staff_lock': null, + /** + * The xblock which is determining the staff lock value. For instance, for a unit, + * this will either be the parent subsection or the grandparent section. + * This can be null if the xblock has no inherited staff lock. + */ + 'staff_lock_from': null, + /** + * True iff this xblock should display a "Contains staff only content" message. + */ + 'staff_only_message': null }, initialize: function () { @@ -135,6 +153,10 @@ function(Backbone, _, str, ModuleUtils) { return childInfo && childInfo.children.length > 0; }, + isPublishable: function(){ + return !this.get('published') || this.get('has_changes'); + }, + /** * Return a list of convenience methods to check affiliation to the category. * @return {Array} @@ -157,7 +179,7 @@ function(Backbone, _, str, ModuleUtils) { * @return {Boolean} */ isEditableOnCourseOutline: function() { - return this.isSequential() || this.isChapter(); + return this.isSequential() || this.isChapter() || this.isVertical(); } }); return XBlockInfo; diff --git a/cms/static/js/spec/models/group_configuration_spec.js b/cms/static/js/spec/models/group_configuration_spec.js index 550d1426bc..4a1b183f19 100644 --- a/cms/static/js/spec/models/group_configuration_spec.js +++ b/cms/static/js/spec/models/group_configuration_spec.js @@ -183,15 +183,14 @@ define([ expect(model.isValid()).toBeTruthy(); }); - it('requires at least two groups', function() { + it('requires at least one group', function() { var group1 = new GroupModel({ name: 'Group A' }), - group2 = new GroupModel({ name: 'Group B' }), model = new GroupConfigurationModel({ name: 'foo' }); - model.get('groups').reset([group1]); + model.get('groups').reset([]); expect(model.isValid()).toBeFalsy(); - model.get('groups').add(group2); + model.get('groups').add(group1); expect(model.isValid()).toBeTruthy(); }); diff --git a/cms/static/js/spec/models/xblock_info_spec.js b/cms/static/js/spec/models/xblock_info_spec.js index 3930d5d5df..5074407478 100644 --- a/cms/static/js/spec/models/xblock_info_spec.js +++ b/cms/static/js/spec/models/xblock_info_spec.js @@ -1,11 +1,11 @@ define(['backbone', 'js/models/xblock_info'], - function(Backbone, XBlockInfo) { + 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); + expect(new XBlockInfo({'category': 'vertical'}).isEditableOnCourseOutline()).toBe(true); }); }); } diff --git a/cms/static/js/spec/views/group_configuration_spec.js b/cms/static/js/spec/views/group_configuration_spec.js index 3b3d75be9a..c9fa5e7925 100644 --- a/cms/static/js/spec/views/group_configuration_spec.js +++ b/cms/static/js/spec/views/group_configuration_spec.js @@ -381,7 +381,7 @@ define([ this.view.$('form').submit(); // See error message expect(this.view.$(SELECTORS.errorMessage)).toContainText( - 'Group Configuration name is required' + 'Group Configuration name is required.' ); // No request expect(requests.length).toBe(0); diff --git a/cms/static/js/spec/views/modals/edit_xblock_spec.js b/cms/static/js/spec/views/modals/edit_xblock_spec.js index ecc12b43f6..301c1b8a44 100644 --- a/cms/static/js/spec/views/modals/edit_xblock_spec.js +++ b/cms/static/js/spec/views/modals/edit_xblock_spec.js @@ -71,8 +71,8 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers refreshed = true; }; modal = showModal(requests, mockXBlockEditorHtml, { refresh: refresh }); - modal.runtime.notify('save', { state: 'start' }); - modal.runtime.notify('save', { state: 'end' }); + modal.editorView.notifyRuntime('save', { state: 'start' }); + modal.editorView.notifyRuntime('save', { state: 'end' }); expect(edit_helpers.isShowingModal(modal)).toBeFalsy(); expect(refreshed).toBeTruthy(); }); @@ -84,7 +84,7 @@ define(["jquery", "underscore", "js/spec_helpers/create_sinon", "js/spec_helpers refreshed = true; }; modal = showModal(requests, mockXBlockEditorHtml, { refresh: refresh }); - modal.runtime.notify('cancel'); + modal.editorView.notifyRuntime('cancel'); expect(edit_helpers.isShowingModal(modal)).toBeFalsy(); expect(refreshed).toBeFalsy(); }); diff --git a/cms/static/js/spec/views/pages/container_spec.js b/cms/static/js/spec/views/pages/container_spec.js index cd93a86fc6..1089ec47a6 100644 --- a/cms/static/js/spec/views/pages/container_spec.js +++ b/cms/static/js/spec/views/pages/container_spec.js @@ -7,6 +7,8 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin model, containerPage, requests, initialDisplayName, mockContainerPage = readFixtures('mock/mock-container-page.underscore'), mockContainerXBlockHtml = readFixtures('mock/mock-container-xblock.underscore'), + mockBadContainerXBlockHtml = readFixtures('mock/mock-bad-javascript-container-xblock.underscore'), + mockBadXBlockContainerXBlockHtml = readFixtures('mock/mock-bad-xblock-container-xblock.underscore'), mockUpdatedContainerXBlockHtml = readFixtures('mock/mock-updated-container-xblock.underscore'), mockXBlockEditorHtml = readFixtures('mock/mock-xblock-editor.underscore'); @@ -15,6 +17,7 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin edit_helpers.installEditTemplates(); edit_helpers.installTemplate('xblock-string-field-editor'); + edit_helpers.installTemplate('container-message'); appendSetFixtures(mockContainerPage); edit_helpers.installMockXBlock({ @@ -83,6 +86,18 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin expect(containerPage.$('.ui-loading')).toHaveClass('is-hidden'); }); + it('can show an xblock with broken JavaScript', function() { + renderContainerPage(this, mockBadContainerXBlockHtml); + expect(containerPage.$('.wrapper-xblock .level-nesting')).not.toHaveClass('is-hidden'); + expect(containerPage.$('.ui-loading')).toHaveClass('is-hidden'); + }); + + it('can show an xblock with an invalid XBlock', function() { + renderContainerPage(this, mockBadXBlockContainerXBlockHtml); + expect(containerPage.$('.wrapper-xblock .level-nesting')).not.toHaveClass('is-hidden'); + expect(containerPage.$('.ui-loading')).toHaveClass('is-hidden'); + }); + it('inline edits the display name when performing a new action', function() { renderContainerPage(this, mockContainerXBlockHtml, { action: 'new' @@ -138,6 +153,9 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin resources: [] }); + // Respond to the subsequent xblock info fetch request. + create_sinon.respondWithJson(requests, {"display_name": updatedDisplayName}); + // Expect the title to have been updated expect(displayNameElement.text().trim()).toBe(updatedDisplayName); }); @@ -177,6 +195,18 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin }); expect(edit_helpers.isShowingModal()).toBeTruthy(); }); + + it('can show an edit modal for a child xblock with broken JavaScript', function() { + var editButtons; + renderContainerPage(this, mockBadContainerXBlockHtml); + editButtons = containerPage.$('.wrapper-xblock .edit-button'); + editButtons[0].click(); + create_sinon.respondWithJson(requests, { + html: mockXBlockEditorHtml, + resources: [] + }); + expect(edit_helpers.isShowingModal()).toBeTruthy(); + }); }); describe("Editing an xmodule", function() { @@ -268,10 +298,10 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin clickDelete(componentIndex); create_sinon.respondWithJson(requests, {}); - // first request contains given component's id (to delete the component) - expect(requests[requests.length - 2].url).toMatch( - new RegExp("locator-component-" + GROUP_TO_TEST + (componentIndex + 1)) - ); + // second to last request contains given component's id (to delete the component) + create_sinon.expectJsonRequest(requests, 'DELETE', + '/xblock/locator-component-' + GROUP_TO_TEST + (componentIndex + 1), + null, requests.length - 2); // final request to refresh the xblock info create_sinon.expectJsonRequest(requests, 'GET', '/xblock/locator-container'); @@ -302,6 +332,18 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin deleteComponentWithSuccess(NUM_COMPONENTS_PER_GROUP - 1); }); + it("can delete an xblock with broken JavaScript", function() { + renderContainerPage(this, mockBadContainerXBlockHtml); + containerPage.$('.delete-button').first().click(); + edit_helpers.confirmPrompt(promptSpy); + create_sinon.respondWithJson(requests, {}); + // expect the second to last request to be a delete of the xblock + create_sinon.expectJsonRequest(requests, 'DELETE', '/xblock/locator-broken-javascript', + null, requests.length - 2); + // expect the last request to be a fetch of the xblock info for the parent container + create_sinon.expectJsonRequest(requests, 'GET', '/xblock/locator-container'); + }); + it('does not delete when clicking No in prompt', function () { var numRequests; @@ -387,6 +429,15 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin duplicateComponentWithSuccess(NUM_COMPONENTS_PER_GROUP - 1); }); + it("can duplicate an xblock with broken JavaScript", function() { + renderContainerPage(this, mockBadContainerXBlockHtml); + containerPage.$('.duplicate-button').first().click(); + create_sinon.expectJsonRequest(requests, 'POST', '/xblock/', { + 'duplicate_source_locator': 'locator-broken-javascript', + 'parent_locator': 'locator-container' + }); + }); + it('shows a notification when duplicating', function () { var notificationSpy = edit_helpers.createNotificationSpy(); renderContainerPage(this, mockContainerXBlockHtml); diff --git a/cms/static/js/spec/views/pages/container_subviews_spec.js b/cms/static/js/spec/views/pages/container_subviews_spec.js index fc3dca0e89..fca5939de0 100644 --- a/cms/static/js/spec/views/pages/container_subviews_spec.js +++ b/cms/static/js/spec/views/pages/container_subviews_spec.js @@ -124,6 +124,8 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin lastDraftCss = ".wrapper-last-draft", releaseDateTitleCss = ".wrapper-release .title", releaseDateContentCss = ".wrapper-release .copy", + releaseDateDateCss = ".wrapper-release .copy .release-date", + releaseDateWithCss = ".wrapper-release .copy .release-with", promptSpies, sendDiscardChangesToServer, verifyPublishingBitUnscheduled; sendDiscardChangesToServer = function() { @@ -342,8 +344,8 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin 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"'); + expect(containerPage.$(releaseDateDateCss).text()).toContain('Jul 02, 2014 at 14:20 UTC'); + expect(containerPage.$(releaseDateWithCss).text()).toContain('with Section "Week 1"'); }); it('renders correctly when released', function () { @@ -353,8 +355,8 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin 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"'); + expect(containerPage.$(releaseDateDateCss).text()).toContain('Jul 02, 2014 at 14:20 UTC'); + expect(containerPage.$(releaseDateWithCss).text()).toContain('with Section "Week 1"'); }); it('renders correctly when the release date is not set', function () { @@ -375,20 +377,22 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin }); 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"'); + expect(containerPage.$(releaseDateDateCss).text()).toContain('Jul 02, 2014 at 14:20 UTC'); + expect(containerPage.$(releaseDateWithCss).text()).toContain('with Section "Week 1"'); }); }); describe("Content Visibility", function () { - var requestStaffOnly, verifyStaffOnly, promptSpy, + var requestStaffOnly, verifyStaffOnly, verifyExplicitStaffOnly, verifyImplicitStaffOnly, promptSpy, visibilityTitleCss = '.wrapper-visibility .title'; requestStaffOnly = function(isStaffOnly) { + var newVisibilityState; + containerPage.$('.action-staff-lock').click(); - // If removing the staff lock, click 'Yes' to confirm - if (!isStaffOnly) { + // If removing explicit staff lock with no implicit staff lock, click 'Yes' to confirm + if (!isStaffOnly && !containerPage.model.get('ancestor_has_staff_lock')) { edit_helpers.confirmPrompt(promptSpy); } @@ -403,24 +407,46 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin visible_to_staff_only: isStaffOnly ? true : null } }); + create_sinon.expectJsonRequest(requests, 'GET', '/xblock/locator-container'); + if (isStaffOnly || containerPage.model.get('ancestor_has_staff_lock')) { + newVisibilityState = VisibilityState.staffOnly; + } else { + newVisibilityState = VisibilityState.live; + } create_sinon.respondWithJson(requests, createXBlockInfo({ published: containerPage.model.get('published'), - visibility_state: isStaffOnly ? VisibilityState.staffOnly : VisibilityState.live, + has_explicit_staff_lock: isStaffOnly, + visibility_state: newVisibilityState, 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.$('.wrapper-visibility .copy').text()).toContain('Staff Only'); expect(containerPage.$(bitPublishingCss)).toHaveClass(staffOnlyClass); - expect(containerPage.$(bitPublishingCss)).toHaveClass(scheduledClass); + } else { + expect(containerPage.$('.wrapper-visibility .copy').text().trim()).toBe('Staff and Students'); + expect(containerPage.$(bitPublishingCss)).not.toHaveClass(staffOnlyClass); + verifyExplicitStaffOnly(false); + verifyImplicitStaffOnly(false); + } + }; + + verifyExplicitStaffOnly = function(isStaffOnly) { + if (isStaffOnly) { + expect(containerPage.$('.action-staff-lock i')).toHaveClass('icon-check'); } 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); + } + }; + + verifyImplicitStaffOnly = function(isStaffOnly) { + if (isStaffOnly) { + expect(containerPage.$('.wrapper-visibility .inherited-from')).toExist(); + } else { + expect(containerPage.$('.wrapper-visibility .inherited-from')).not.toExist(); } }; @@ -444,36 +470,79 @@ define(["jquery", "underscore", "underscore.string", "js/spec_helpers/create_sin published: true, has_changes: true }); - expect(containerPage.$(visibilityTitleCss).text()).toContain('Will Be Visible To') + expect(containerPage.$(visibilityTitleCss).text()).toContain('Will Be Visible To'); }); - it("can be set to staff only", function() { + it("can be explicitly set to staff only", function() { renderContainerPage(this, mockContainerXBlockHtml); requestStaffOnly(true); + verifyExplicitStaffOnly(true); + verifyImplicitStaffOnly(false); verifyStaffOnly(true); }); - it("can remove staff only setting", function() { + it("can be implicitly set to staff only", function() { + renderContainerPage(this, mockContainerXBlockHtml, { + visibility_state: VisibilityState.staffOnly, + ancestor_has_staff_lock: true, + staff_lock_from: "Section Foo" + }); + verifyImplicitStaffOnly(true); + verifyExplicitStaffOnly(false); + verifyStaffOnly(true); + }); + + it("can be explicitly and implicitly set to staff only", function() { + renderContainerPage(this, mockContainerXBlockHtml, { + visibility_state: VisibilityState.staffOnly, + ancestor_has_staff_lock: true, + staff_lock_from: "Section Foo" + }); + requestStaffOnly(true); + // explicit staff lock overrides the display of implicit staff lock + verifyImplicitStaffOnly(false); + verifyExplicitStaffOnly(true); + verifyStaffOnly(true); + }); + + it("can remove explicit staff only setting without having implicit staff only", function() { promptSpy = edit_helpers.createPromptSpy(); renderContainerPage(this, mockContainerXBlockHtml, { visibility_state: VisibilityState.staffOnly, - release_date: "Jul 02, 2000 at 14:20 UTC" + has_explicit_staff_lock: true, + ancestor_has_staff_lock: false }); requestStaffOnly(false); verifyStaffOnly(false); }); + it("can remove explicit staff only setting while having implicit staff only", function() { + promptSpy = edit_helpers.createPromptSpy(); + renderContainerPage(this, mockContainerXBlockHtml, { + visibility_state: VisibilityState.staffOnly, + ancestor_has_staff_lock: true, + has_explicit_staff_lock: true, + staff_lock_from: "Section Foo" + }); + requestStaffOnly(false); + verifyExplicitStaffOnly(false); + verifyImplicitStaffOnly(true); + verifyStaffOnly(true); + }); + 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" + has_explicit_staff_lock: true, + ancestor_has_staff_lock: false }); requestCount = requests.length; containerPage.$('.action-staff-lock').click(); edit_helpers.confirmPrompt(promptSpy, true); // Click 'No' to cancel expect(requests.length).toBe(requestCount); + verifyExplicitStaffOnly(true); verifyStaffOnly(true); }); diff --git a/cms/static/js/spec/views/pages/course_outline_spec.js b/cms/static/js/spec/views/pages/course_outline_spec.js index fc55c788a3..a13537051d 100644 --- a/cms/static/js/spec/views/pages/course_outline_spec.js +++ b/cms/static/js/spec/views/pages/course_outline_spec.js @@ -5,14 +5,15 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_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, createMockSectionJSON, createMockSubsectionJSON, verifyTypePublishable, + mockCourseJSON, mockEmptyCourseJSON, mockSingleSectionCourseJSON, createMockVerticalJSON, + mockOutlinePage = readFixtures('mock/mock-course-outline-page.underscore'), + mockRerunNotification = readFixtures('mock/mock-course-rerun-notification.underscore'); - createMockCourseJSON = function(id, displayName, children) { - return { - id: id, - display_name: displayName, + createMockCourseJSON = function(options, children) { + return $.extend(true, {}, { + id: 'mock-course', + display_name: 'Mock Course', category: 'course', studio_url: '/course/slashes:MockCourse', is_container: true, @@ -20,35 +21,39 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" published: true, edited_on: 'Jul 02, 2014 at 20:56 UTC', edited_by: 'MockUser', + has_explicit_staff_lock: false, child_info: { - display_name: 'Section', category: 'chapter', - children: children + display_name: 'Section', + children: [] } - }; + }, options, {child_info: {children: children}}); }; - createMockSectionJSON = function(id, displayName, children) { - return { - id: id, + + createMockSectionJSON = function(options, children) { + return $.extend(true, {}, { + id: 'mock-section', + display_name: 'Mock Section', 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', + has_explicit_staff_lock: false, child_info: { category: 'sequential', display_name: 'Subsection', - children: children + children: [] } - }; + }, options, {child_info: {children: children}}); }; - createMockSubsectionJSON = function(id, displayName, children) { - return { - id: id, - display_name: displayName, + + createMockSubsectionJSON = function(options, children) { + return $.extend(true, {}, { + id: 'mock-subsection', + display_name: 'Mock Subsection', category: 'sequential', studio_url: '/course/slashes:MockCourse', is_container: true, @@ -57,12 +62,28 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" edited_on: 'Jul 02, 2014 at 20:56 UTC', edited_by: 'MockUser', course_graders: '["Lab", "Howework"]', + has_explicit_staff_lock: false, child_info: { category: 'vertical', display_name: 'Unit', - children: children + children: [] } - }; + }, options, {child_info: {children: children}}); + }; + + createMockVerticalJSON = function(options) { + return $.extend(true, {}, { + 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' + }, options); }; getItemsOfType = function(type) { @@ -105,40 +126,100 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" return outlinePage; }; + verifyTypePublishable = function (type, getMockCourseJSON) { + var createCourseOutlinePageAndShowUnit, verifyPublishButton; + + createCourseOutlinePageAndShowUnit = function (test, courseJSON, createOnly) { + outlinePage = createCourseOutlinePage.apply(this, arguments); + if (type === 'unit') { + expandItemsAndVerifyState('subsection'); + } + }; + + verifyPublishButton = function (test, courseJSON, createOnly) { + createCourseOutlinePageAndShowUnit.apply(this, arguments); + expect(getItemHeaders(type).find('.publish-button')).toExist(); + }; + + it('can be published', function() { + var mockCourseJSON = getMockCourseJSON({ + has_changes: true + }); + createCourseOutlinePageAndShowUnit(this, mockCourseJSON); + getItemHeaders(type).find('.publish-button').click(); + $(".wrapper-modal-window .action-publish").click(); + create_sinon.expectJsonRequest(requests, 'POST', '/xblock/mock-' + type, { + publish : 'make_public' + }); + expect(requests[0].requestHeaders['X-HTTP-Method-Override']).toBe('PATCH'); + create_sinon.respondWithJson(requests, {}); + create_sinon.expectJsonRequest(requests, 'GET', '/xblock/outline/mock-section'); + }); + + it('should show publish button if it is not published and not changed', function() { + var mockCourseJSON = getMockCourseJSON({ + has_changes: false, + published: false + }); + verifyPublishButton(this, mockCourseJSON); + }); + + it('should show publish button if it is published and changed', function() { + var mockCourseJSON = getMockCourseJSON({ + has_changes: true, + published: true + }); + verifyPublishButton(this, mockCourseJSON); + }); + + it('should show publish button if it is not published, but changed', function() { + var mockCourseJSON = getMockCourseJSON({ + has_changes: true, + published: false + }); + verifyPublishButton(this, mockCourseJSON); + }); + + it('should hide publish button if it is not changed, but published', function() { + var mockCourseJSON = getMockCourseJSON({ + has_changes: false, + published: true + }); + createCourseOutlinePageAndShowUnit(this, mockCourseJSON); + expect(getItemHeaders(type).find('.publish-button')).not.toExist(); + }); + }; + 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'); + view_helpers.installTemplates([ + 'course-outline', 'xblock-string-field-editor', 'modal-button', + 'basic-modal', 'course-outline-modal', 'release-date-editor', + 'due-date-editor', 'grading-editor', 'publish-editor', + 'staff-lock-editor' + ]); appendSetFixtures(mockOutlinePage); - mockCourseJSON = createMockCourseJSON('mock-course', 'Mock Course', [ - createMockSectionJSON('mock-section', 'Mock Section', [ - createMockSubsectionJSON('mock-subsection', 'Mock Subsection', [{ - id: 'mock-unit', - display_name: 'Mock Unit', - category: 'vertical', - studio_url: '/container/mock-unit', - is_container: true, - has_changes: false, - published: true, - visibility_state: 'unscheduled', - edited_on: 'Jul 02, 2014 at 20:56 UTC', - edited_by: 'MockUser' - }]) + mockCourseJSON = createMockCourseJSON({}, [ + createMockSectionJSON({}, [ + createMockSubsectionJSON({}, [ + createMockVerticalJSON() + ]) ]) ]); - mockEmptyCourseJSON = createMockCourseJSON('mock-course', 'Mock Course', []); - mockSingleSectionCourseJSON = createMockCourseJSON('mock-course', 'Mock Course', [ - createMockSectionJSON('mock-section', 'Mock Section', []) + mockEmptyCourseJSON = createMockCourseJSON(); + mockSingleSectionCourseJSON = createMockCourseJSON({}, [ + createMockSectionJSON() ]); }); afterEach(function () { view_helpers.removeMockAnalytics(); edit_helpers.cancelModalIfShowing(); + // Clean up after the $.datepicker + $("#start_date").datepicker( "destroy" ); + $("#due_date").datepicker( "destroy" ); + $('.ui-datepicker').remove(); }); describe('Initial display', function() { @@ -163,6 +244,18 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" }); }); + describe("Rerun notification", function () { + it("can be dismissed", function () { + appendSetFixtures(mockRerunNotification); + createCourseOutlinePage(this, mockEmptyCourseJSON); + expect($('.wrapper-alert-announcement')).not.toHaveClass('is-hidden'); + $('.dismiss-button').click(); + create_sinon.expectJsonRequest(requests, 'DELETE', 'dummy_dismiss_url'); + create_sinon.respondToDelete(requests); + expect($('.wrapper-alert-announcement')).toHaveClass('is-hidden'); + }); + }); + describe("Button bar", function() { it('can add a section', function() { createCourseOutlinePage(this, mockEmptyCourseJSON); @@ -198,7 +291,7 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" // 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', [])); + createMockSectionJSON({id: 'mock-section-2', display_name: 'Mock Section 2'})); sectionElements = getItemsOfType('section'); expect(sectionElements.length).toBe(2); expect($(sectionElements[0]).data('locator')).toEqual('mock-section'); @@ -266,9 +359,9 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" 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', []) + createCourseOutlinePage(this, createMockCourseJSON({}, [ + createMockSectionJSON(), + createMockSectionJSON({id: 'mock-section-2', display_name: 'Mock Section 2'}) ])); getItemHeaders('section').find('.delete-button').first().click(); view_helpers.confirmPrompt(promptSpy); @@ -353,42 +446,34 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" 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(); + expect($(".wrapper-modal-window .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(); - + // Staff lock controls are always visible + expect($("#staff_lock")).toExist(); + $(".wrapper-modal-window .action-save").click(); create_sinon.expectJsonRequest(requests, 'POST', '/xblock/mock-section', { "metadata":{ - "start":"2015-01-02T00:00:00.000Z", + "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' - } + var mockResponseSectionJSON = createMockSectionJSON({ + release_date: 'Jan 02, 2015 at 00:00 UTC' + }, [ + createMockSubsectionJSON({}, [ + createMockVerticalJSON({ + has_changes: true, + published: false + }) ]) - ]), - { - 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. @@ -396,6 +481,46 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" expect($(".outline-section .status-release-value")).toContainText("Jan 02, 2015 at 00:00 UTC"); }); + + verifyTypePublishable('section', function (options) { + return createMockCourseJSON({}, [ + createMockSectionJSON(options, [ + createMockSubsectionJSON({}, [ + createMockVerticalJSON() + ]) + ]) + ]); + }); + + it('can display a publish modal with a list of unpublished subsections and units', function () { + var mockCourseJSON = createMockCourseJSON({}, [ + createMockSectionJSON({has_changes: true}, [ + createMockSubsectionJSON({has_changes: true}, [ + createMockVerticalJSON(), + createMockVerticalJSON({has_changes: true, display_name: 'Unit 100'}), + createMockVerticalJSON({published: false, display_name: 'Unit 50'}) + ]), + createMockSubsectionJSON({has_changes: true}, [ + createMockVerticalJSON({has_changes: true, display_name: 'Unit 1'}) + ]), + createMockSubsectionJSON({}, [createMockVerticalJSON]) + ]), + createMockSectionJSON({has_changes: true}, [ + createMockSubsectionJSON({has_changes: true}, [ + createMockVerticalJSON({has_changes: true}), + ]) + ]) + ]), modalWindow; + + createCourseOutlinePage(this, mockCourseJSON, false); + getItemHeaders('section').first().find('.publish-button').click(); + modalWindow = $('.wrapper-modal-window'); + expect(modalWindow.find('.outline-unit').length).toBe(3); + expect(_.compact(_.map(modalWindow.find('.outline-unit').text().split("\n"), $.trim))).toEqual( + ['Unit 100', 'Unit 50', 'Unit 1'] + ) + expect(modalWindow.find('.outline-subsection').length).toBe(2); + }); }); describe("Subsection", function() { @@ -405,42 +530,33 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" return getItemHeaders('subsection').find('.wrapper-xblock-field'); }; - setEditModalValues = function (start_date, due_date, grading_type) { + setEditModalValues = function (start_date, due_date, grading_type, is_locked) { $("#start_date").val(start_date); $("#due_date").val(due_date); $("#grading_type").val(grading_type); - } + $("#staff_lock").prop('checked', is_locked); + }; // 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' - } + var mockServerValuesJson = createMockSectionJSON({ + release_date: 'Jan 01, 2970 at 05:00 UTC' + }, [ + createMockSubsectionJSON({ + 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", + has_explicit_staff_lock: true, + staff_only_message: true + }, [ + createMockVerticalJSON({ + has_changes: true, + published: false + }) ]) - ]), - { - 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(); @@ -483,9 +599,12 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" 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, []) - ])); + createMockSectionJSON({}, [ + createMockSubsectionJSON({ + display_name: 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); @@ -504,11 +623,13 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" 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(); + setEditModalValues("7/9/2014", "7/10/2014", "Lab", true); + $(".wrapper-modal-window .action-save").click(); create_sinon.expectJsonRequest(requests, 'POST', '/xblock/mock-subsection', { "graderType":"Lab", + "publish": "republish", "metadata":{ + "visible_to_staff_only": true, "start":"2014-07-09T00:00:00.000Z", "due":"2014-07-10T00:00:00.000Z" } @@ -525,19 +646,21 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" 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-subsection .status-message-copy")).toContainText("Contains staff only content"); 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'); + expect($("#staff_lock").is(":checked")).toBe(true); }); - it('release date, due date and grading type can be cleared.', function() { + it('release date, due date, grading type, and staff lock 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(); + setEditModalValues("7/9/2014", "7/10/2014", "Lab", true); + $(".wrapper-modal-window .action-save").click(); // This is the response for the change operation. create_sinon.respondWithJson(requests, {}); @@ -547,32 +670,69 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" 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-subsection .status-message-copy")).toContainText("Contains staff only content"); 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'); + expect($("#staff_lock").is(":checked")).toBe(true); - $(".edit-outline-item-modal .scheduled-date-input .action-clear").click(); - $(".edit-outline-item-modal .due-date-input .action-clear").click(); + $(".wrapper-modal-window .scheduled-date-input .action-clear").click(); + $(".wrapper-modal-window .due-date-input .action-clear").click(); expect($("#start_date").val()).toBe(''); expect($("#due_date").val()).toBe(''); $("#grading_type").val('notgraded'); + $("#staff_lock").prop('checked', false); - $(".edit-outline-item-modal .action-save").click(); + $(".wrapper-modal-window .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', []) - ]) + createMockSectionJSON({}, [createMockSubsectionJSON()]) ); 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(); + expect($(".outline-subsection .status-message-copy")).not.toContainText("Contains staff only content"); + }); + + verifyTypePublishable('subsection', function (options) { + return createMockCourseJSON({}, [ + createMockSectionJSON({}, [ + createMockSubsectionJSON(options, [ + createMockVerticalJSON() + ]) + ]) + ]); + }); + + it('can display a publish modal with a list of unpublished units', function () { + var mockCourseJSON = createMockCourseJSON({}, [ + createMockSectionJSON({has_changes: true}, [ + createMockSubsectionJSON({has_changes: true}, [ + createMockVerticalJSON(), + createMockVerticalJSON({has_changes: true, display_name: "Unit 100"}), + createMockVerticalJSON({published: false, display_name: "Unit 50"}) + ]), + createMockSubsectionJSON({has_changes: true}, [ + createMockVerticalJSON({has_changes: true}) + ]), + createMockSubsectionJSON({}, [createMockVerticalJSON]) + ]) + ]), modalWindow; + + createCourseOutlinePage(this, mockCourseJSON, false); + getItemHeaders('subsection').first().find('.publish-button').click(); + modalWindow = $('.wrapper-modal-window'); + expect(modalWindow.find('.outline-unit').length).toBe(2); + expect(_.compact(_.map(modalWindow.find('.outline-unit').text().split("\n"), $.trim))).toEqual( + ['Unit 100', 'Unit 50'] + ) + expect(modalWindow.find('.outline-subsection')).not.toExist(); }); }); @@ -598,6 +758,16 @@ define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers" unitAnchor = getItemsOfType('unit').find('.unit-title a'); expect(unitAnchor.attr('href')).toBe('/container/mock-unit'); }); + + verifyTypePublishable('unit', function (options) { + return createMockCourseJSON({}, [ + createMockSectionJSON({}, [ + createMockSubsectionJSON({}, [ + createMockVerticalJSON(options) + ]) + ]) + ]); + }); }); describe("Date and Time picker", function() { diff --git a/cms/static/js/spec/views/pages/course_rerun_spec.js b/cms/static/js/spec/views/pages/course_rerun_spec.js new file mode 100644 index 0000000000..5a9eb0b87b --- /dev/null +++ b/cms/static/js/spec/views/pages/course_rerun_spec.js @@ -0,0 +1,205 @@ +define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers", "js/views/course_rerun", + "js/views/utils/create_course_utils", "js/views/utils/view_utils", "jquery.simulate"], + function ($, create_sinon, view_helpers, CourseRerunUtils, CreateCourseUtilsFactory, ViewUtils) { + describe("Create course rerun page", function () { + var selectors = { + org: '.rerun-course-org', + number: '.rerun-course-number', + run: '.rerun-course-run', + name: '.rerun-course-name', + tipError: 'span.tip-error', + save: '.rerun-course-save', + cancel: '.rerun-course-cancel', + errorWrapper: '.wrapper-error', + errorMessage: '#course_rerun_error', + error: '.error', + allowUnicode: '.allow-unicode-course-id' + }, + classes = { + shown: 'is-shown', + showing: 'is-showing', + hiding: 'is-hidden', + hidden: 'is-hidden', + error: 'error', + disabled: 'is-disabled', + processing: 'is-processing' + }, + mockCreateCourseRerunHTML = readFixtures('mock/mock-create-course-rerun.underscore'); + + var CreateCourseUtils = CreateCourseUtilsFactory(selectors, classes); + + var fillInFields = function (org, number, run, name) { + $(selectors.org).val(org); + $(selectors.number).val(number); + $(selectors.run).val(run); + $(selectors.name).val(name); + }; + + beforeEach(function () { + view_helpers.installMockAnalytics(); + window.source_course_key = 'test_course_key'; + appendSetFixtures(mockCreateCourseRerunHTML); + CourseRerunUtils.onReady(); + }); + + afterEach(function () { + view_helpers.removeMockAnalytics(); + delete window.source_course_key; + }); + + describe("Field validation", function () { + it("returns a message for an empty string", function () { + var message = CreateCourseUtils.validateRequiredField(''); + expect(message).not.toBe(''); + }); + + it("does not return a message for a non empty string", function () { + var message = CreateCourseUtils.validateRequiredField('edX'); + expect(message).toBe(''); + }); + }); + + describe("Error messages", function () { + var setErrorMessage = function(selector, message) { + var element = $(selector).parent(); + CreateCourseUtils.setNewCourseFieldInErr(element, message); + return element; + }; + + var type = function (input, value) { + input.val(value); + input.simulate("keyup", { keyCode: $.simulate.keyCode.SPACE }); + }; + + it("shows an error message", function () { + var element = setErrorMessage(selectors.org, 'error message'); + expect(element).toHaveClass(classes.error); + expect(element.children(selectors.tipError)).not.toHaveClass(classes.hidden); + expect(element.children(selectors.tipError)).toContainText('error message'); + }); + + it("hides an error message", function () { + var element = setErrorMessage(selectors.org, ''); + expect(element).not.toHaveClass(classes.error); + expect(element.children(selectors.tipError)).toHaveClass(classes.hidden); + }); + + it("disables the save button", function () { + setErrorMessage(selectors.org, 'error message'); + expect($(selectors.save)).toHaveClass(classes.disabled); + }); + + it("enables the save button when all errors are removed", function () { + setErrorMessage(selectors.org, 'error message 1'); + setErrorMessage(selectors.number, 'error message 2'); + expect($(selectors.save)).toHaveClass(classes.disabled); + setErrorMessage(selectors.org, ''); + setErrorMessage(selectors.number, ''); + expect($(selectors.save)).not.toHaveClass(classes.disabled); + }); + + it("does not enable the save button when errors remain", function () { + setErrorMessage(selectors.org, 'error message 1'); + setErrorMessage(selectors.number, 'error message 2'); + expect($(selectors.save)).toHaveClass(classes.disabled); + setErrorMessage(selectors.org, ''); + expect($(selectors.save)).toHaveClass(classes.disabled); + }); + + it("shows an error message when non URL characters are entered", function () { + var input = $(selectors.org); + expect(input.parent()).not.toHaveClass(classes.error); + type(input, "%"); + expect(input.parent()).toHaveClass(classes.error); + }); + + it("does not show an error message when tabbing into a field", function () { + var input = $(selectors.number); + input.val(''); + expect(input.parent()).not.toHaveClass(classes.error); + input.simulate("keyup", { keyCode: $.simulate.keyCode.TAB }); + expect(input.parent()).not.toHaveClass(classes.error); + }); + + it("shows an error message when a required field is empty", function () { + var input = $(selectors.org); + input.val(''); + expect(input.parent()).not.toHaveClass(classes.error); + input.simulate("keyup", { keyCode: $.simulate.keyCode.ENTER }); + expect(input.parent()).toHaveClass(classes.error); + }); + + it("shows an error message when spaces are entered and unicode is allowed", function () { + var input = $(selectors.org); + $(selectors.allowUnicode).val('True'); + expect(input.parent()).not.toHaveClass(classes.error); + type(input, ' '); + expect(input.parent()).toHaveClass(classes.error); + }); + + it("shows an error message when total length exceeds 65 characters", function () { + expect($(selectors.errorWrapper)).not.toHaveClass(classes.shown); + type($(selectors.org), 'ThisIsAVeryLongNameThatWillExceedTheSixtyFiveCharacterLimit'); + type($(selectors.number), 'ThisIsAVeryLongNameThatWillExceedTheSixtyFiveCharacterLimit'); + type($(selectors.run), 'ThisIsAVeryLongNameThatWillExceedTheSixtyFiveCharacterLimit'); + expect($(selectors.errorWrapper)).toHaveClass(classes.shown); + }); + + describe("Name field", function () { + it("does not show an error message when non URL characters are entered", function () { + var input = $(selectors.name); + expect(input.parent()).not.toHaveClass(classes.error); + type(input, "%"); + expect(input.parent()).not.toHaveClass(classes.error); + }); + }); + }); + + it("saves course reruns", function () { + var requests = create_sinon.requests(this); + var redirectSpy = spyOn(ViewUtils, 'redirect') + fillInFields('DemoX', 'DM101', '2014', 'Demo course'); + $(selectors.save).click(); + create_sinon.expectJsonRequest(requests, 'POST', '/course/', { + source_course_key: 'test_course_key', + org: 'DemoX', + number: 'DM101', + run: '2014', + display_name: 'Demo course' + }); + expect($(selectors.save)).toHaveClass(classes.disabled); + expect($(selectors.save)).toHaveClass(classes.processing); + expect($(selectors.cancel)).toHaveClass(classes.hidden); + create_sinon.respondWithJson(requests, { + url: 'dummy_test_url' + }); + expect(redirectSpy).toHaveBeenCalledWith('dummy_test_url'); + }); + + it("displays an error when saving fails", function () { + var requests = create_sinon.requests(this); + fillInFields('DemoX', 'DM101', '2014', 'Demo course'); + $(selectors.save).click(); + create_sinon.respondWithJson(requests, { + ErrMsg: 'error message' + }); + expect($(selectors.errorWrapper)).not.toHaveClass(classes.hidden); + expect($(selectors.errorWrapper)).toContainText('error message'); + expect($(selectors.save)).not.toHaveClass(classes.processing); + expect($(selectors.cancel)).not.toHaveClass(classes.hidden); + }); + + it("does not save if there are validation errors", function () { + var requests = create_sinon.requests(this); + fillInFields('DemoX', 'DM101', '', 'Demo course'); + $(selectors.save).click(); + expect(requests.length).toBe(0); + }); + + it("can be canceled", function () { + var redirectSpy = spyOn(ViewUtils, 'redirect'); + $(selectors.cancel).click(); + expect(redirectSpy).toHaveBeenCalledWith('/course/'); + }); + }); + }); diff --git a/cms/static/js/spec/views/pages/index_spec.js b/cms/static/js/spec/views/pages/index_spec.js new file mode 100644 index 0000000000..fab5b9653e --- /dev/null +++ b/cms/static/js/spec/views/pages/index_spec.js @@ -0,0 +1,65 @@ +define(["jquery", "js/spec_helpers/create_sinon", "js/spec_helpers/view_helpers", "js/index", + "js/views/utils/view_utils"], + function ($, create_sinon, view_helpers, IndexUtils, ViewUtils) { + describe("Course listing page", function () { + var mockIndexPageHTML = readFixtures('mock/mock-index-page.underscore'), fillInFields; + + var fillInFields = function (org, number, run, name) { + $('.new-course-org').val(org); + $('.new-course-number').val(number); + $('.new-course-run').val(run); + $('.new-course-name').val(name); + }; + + beforeEach(function () { + view_helpers.installMockAnalytics(); + appendSetFixtures(mockIndexPageHTML); + IndexUtils.onReady(); + }); + + afterEach(function () { + view_helpers.removeMockAnalytics(); + delete window.source_course_key; + }); + + it("can dismiss notifications", function () { + var requests = create_sinon.requests(this); + var reloadSpy = spyOn(ViewUtils, 'reload'); + $('.dismiss-button').click(); + create_sinon.expectJsonRequest(requests, 'DELETE', 'dummy_dismiss_url'); + create_sinon.respondToDelete(requests); + expect(reloadSpy).toHaveBeenCalled(); + }); + + it("saves new courses", function () { + var requests = create_sinon.requests(this); + var redirectSpy = spyOn(ViewUtils, 'redirect'); + $('.new-course-button').click() + fillInFields('DemoX', 'DM101', '2014', 'Demo course'); + $('.new-course-save').click(); + create_sinon.expectJsonRequest(requests, 'POST', '/course/', { + org: 'DemoX', + number: 'DM101', + run: '2014', + display_name: 'Demo course' + }); + create_sinon.respondWithJson(requests, { + url: 'dummy_test_url' + }); + expect(redirectSpy).toHaveBeenCalledWith('dummy_test_url'); + }); + + it("displays an error when saving fails", function () { + var requests = create_sinon.requests(this); + $('.new-course-button').click(); + fillInFields('DemoX', 'DM101', '2014', 'Demo course'); + $('.new-course-save').click(); + create_sinon.respondWithJson(requests, { + ErrMsg: 'error message' + }); + expect($('.wrap-error')).toHaveClass('is-shown'); + expect($('#course_creation_error')).toContainText('error message'); + expect($('.new-course-save')).toHaveClass('is-disabled'); + }); + }); + }); diff --git a/cms/static/js/utils/drag_and_drop.js b/cms/static/js/utils/drag_and_drop.js index 69d62fcdf9..8a7ef8d3aa 100644 --- a/cms/static/js/utils/drag_and_drop.js +++ b/cms/static/js/utils/drag_and_drop.js @@ -260,6 +260,10 @@ define(["jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notif }, expandElement: function (ele) { + // Verify all children of the element are rendered. + var ensureChildrenRendered = ele.data('ensureChildrenRendered'); + if (_.isFunction(ensureChildrenRendered)) { ensureChildrenRendered(); } + // Update classes. ele.removeClass(this.collapsedClass); ele.find('.expand-collapse').first().removeClass('expand').addClass('collapse'); }, @@ -354,7 +358,8 @@ define(["jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notif handleClass: null, droppableClass: null, parentLocationSelector: null, - refresh: null + refresh: null, + ensureChildrenRendered: null }, options); if ($(element).data('droppable-class') !== options.droppableClass) { @@ -362,7 +367,8 @@ define(["jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notif 'droppable-class': options.droppableClass, 'parent-location-selector': options.parentLocationSelector, 'child-selector': options.type, - 'refresh': options.refresh + 'refresh': options.refresh, + 'ensureChildrenRendered': options.ensureChildrenRendered }); draggable = new Draggabilly(element, { diff --git a/cms/static/js/views/container.js b/cms/static/js/views/container.js index 60f38afaf4..0dfcae22aa 100644 --- a/cms/static/js/views/container.js +++ b/cms/static/js/views/container.js @@ -1,22 +1,23 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", "js/views/feedback_notification", "jquery.ui"], // The container view uses sortable, which is provided by jquery.ui. function ($, _, XBlockView, ModuleUtils, gettext, NotificationView) { - var reorderableClass = '.reorderable-container', - sortableInitializedClass = '.ui-sortable', - studioXBlockWrapperClass = '.studio-xblock-wrapper'; + var studioXBlockWrapperClass = '.studio-xblock-wrapper'; var ContainerView = XBlockView.extend({ + // Store the request token of the first xblock on the page (which we know was rendered by Studio when + // the page was generated). Use that request token to filter out user-defined HTML in any + // child xblocks within the page. + requestToken: "", xblockReady: function () { XBlockView.prototype.xblockReady.call(this); - var reorderableContainer = this.$(reorderableClass), - alreadySortable = this.$(sortableInitializedClass), - newParent, - oldParent, - self = this; + var reorderableClass, reorderableContainer, + newParent, oldParent, self = this; - alreadySortable.sortable("destroy"); + this.requestToken = this.$('div.xblock').first().data('request-token'); + reorderableClass = this.makeRequestSpecificSelector('.reorderable-container'); + reorderableContainer = this.$(reorderableClass); reorderableContainer.sortable({ handle: '.drag-handle', @@ -123,7 +124,12 @@ define(["jquery", "underscore", "js/views/xblock", "js/utils/module", "gettext", }, refresh: function() { + var sortableInitializedClass = this.makeRequestSpecificSelector('.reorderable-container.ui-sortable'); this.$(sortableInitializedClass).sortable('refresh'); + }, + + makeRequestSpecificSelector: function(selector) { + return 'div.xblock[data-request-token="' + this.requestToken + '"] > ' + selector; } }); diff --git a/cms/static/js/views/course_outline.js b/cms/static/js/views/course_outline.js index 0faf546ec6..d693bd5aee 100644 --- a/cms/static/js/views/course_outline.js +++ b/cms/static/js/views/course_outline.js @@ -8,9 +8,12 @@ * - 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) { +define(["jquery", "underscore", "js/views/xblock_outline", "js/views/utils/view_utils", "js/views/utils/xblock_utils", + "js/models/xblock_outline_info", "js/views/modals/course_outline_modals", "js/utils/drag_and_drop"], + function( + $, _, XBlockOutlineView, ViewUtils, XBlockViewUtils, + XBlockOutlineInfo, CourseOutlineModalsFactory, ContentDragger + ) { var CourseOutlineView = XBlockOutlineView.extend({ // takes XBlockOutlineInfo as a model @@ -144,12 +147,30 @@ define(["jquery", "underscore", "js/views/xblock_outline", "js/views/utils/view_ }, editXBlock: function() { - var modal = new EditSectionXBlockModal({ - model: this.model, - onSave: this.refresh.bind(this) + var modal = CourseOutlineModalsFactory.getModal('edit', this.model, { + onSave: this.refresh.bind(this), + parentInfo: this.parentInfo, + xblockType: XBlockViewUtils.getXBlockType( + this.model.get('category'), this.parentView.model, true + ) }); - modal.show(); + if (modal) { + modal.show(); + } + }, + + publishXBlock: function() { + var modal = CourseOutlineModalsFactory.getModal('publish', this.model, { + onSave: this.refresh.bind(this), + xblockType: XBlockViewUtils.getXBlockType( + this.model.get('category'), this.parentView.model, true + ) + }); + + if (modal) { + modal.show(); + } }, addButtonActions: function(element) { @@ -158,6 +179,10 @@ define(["jquery", "underscore", "js/views/xblock_outline", "js/views/utils/view_ event.preventDefault(); this.editXBlock(); }.bind(this)); + element.find('.publish-button').click(function(event) { + event.preventDefault(); + this.publishXBlock(); + }.bind(this)); }, makeContentDraggable: function(element) { @@ -167,7 +192,8 @@ define(["jquery", "underscore", "js/views/xblock_outline", "js/views/utils/view_ handleClass: '.section-drag-handle', droppableClass: 'ol.list-sections', parentLocationSelector: 'article.outline', - refresh: this.refreshWithCollapsedState.bind(this) + refresh: this.refreshWithCollapsedState.bind(this), + ensureChildrenRendered: this.ensureChildrenRendered.bind(this) }); } else if ($(element).hasClass("outline-subsection")) { @@ -176,7 +202,8 @@ define(["jquery", "underscore", "js/views/xblock_outline", "js/views/utils/view_ handleClass: '.subsection-drag-handle', droppableClass: 'ol.list-subsections', parentLocationSelector: 'li.outline-section', - refresh: this.refreshWithCollapsedState.bind(this) + refresh: this.refreshWithCollapsedState.bind(this), + ensureChildrenRendered: this.ensureChildrenRendered.bind(this) }); } else if ($(element).hasClass("outline-unit")) { @@ -185,7 +212,8 @@ define(["jquery", "underscore", "js/views/xblock_outline", "js/views/utils/view_ handleClass: '.unit-drag-handle', droppableClass: 'ol.list-units', parentLocationSelector: 'li.outline-subsection', - refresh: this.refreshWithCollapsedState.bind(this) + refresh: this.refreshWithCollapsedState.bind(this), + ensureChildrenRendered: this.ensureChildrenRendered.bind(this) }); } } diff --git a/cms/static/js/views/course_rerun.js b/cms/static/js/views/course_rerun.js new file mode 100644 index 0000000000..bfa6370d1b --- /dev/null +++ b/cms/static/js/views/course_rerun.js @@ -0,0 +1,87 @@ +define(["domReady", "jquery", "underscore", "js/views/utils/create_course_utils", "js/views/utils/view_utils"], + function (domReady, $, _, CreateCourseUtilsFactory, ViewUtils) { + var CreateCourseUtils = CreateCourseUtilsFactory({ + name: '.rerun-course-name', + org: '.rerun-course-org', + number: '.rerun-course-number', + run: '.rerun-course-run', + save: '.rerun-course-save', + errorWrapper: '.wrapper-error', + errorMessage: '#course_rerun_error', + tipError: 'span.tip-error', + error: '.error', + allowUnicode: '.allow-unicode-course-id' + }, { + shown: 'is-shown', + showing: 'is-showing', + hiding: 'is-hidden', + disabled: 'is-disabled', + error: 'error' + }); + + var saveRerunCourse = function (e) { + e.preventDefault(); + + if (CreateCourseUtils.hasInvalidRequiredFields()) { + return; + } + + var $newCourseForm = $(this).closest('#rerun-course-form'); + var display_name = $newCourseForm.find('.rerun-course-name').val(); + var org = $newCourseForm.find('.rerun-course-org').val(); + var number = $newCourseForm.find('.rerun-course-number').val(); + var run = $newCourseForm.find('.rerun-course-run').val(); + + course_info = { + source_course_key: source_course_key, + org: org, + number: number, + display_name: display_name, + run: run + }; + + analytics.track('Reran a Course', course_info); + CreateCourseUtils.createCourse(course_info, function (errorMessage) { + $('.wrapper-error').addClass('is-shown').removeClass('is-hidden'); + $('#course_rerun_error').html('

' + errorMessage + '

'); + $('.rerun-course-save').addClass('is-disabled').removeClass('is-processing').html(gettext('Create Re-run')); + $('.action-cancel').removeClass('is-hidden'); + }); + + // Go into creating re-run state + $('.rerun-course-save').addClass('is-disabled').addClass('is-processing').html( + '' + gettext('Processing Re-run Request') + ); + $('.action-cancel').addClass('is-hidden'); + }; + + var cancelRerunCourse = function (e) { + e.preventDefault(); + // Clear out existing fields and errors + $('.rerun-course-run').val(''); + $('#course_rerun_error').html(''); + $('wrapper-error').removeClass('is-shown').addClass('is-hidden'); + $('.rerun-course-save').off('click'); + ViewUtils.redirect('/course/'); + }; + + var onReady = function () { + var $cancelButton = $('.rerun-course-cancel'); + var $courseRun = $('.rerun-course-run'); + $courseRun.focus().select(); + $('.rerun-course-save').on('click', saveRerunCourse); + $cancelButton.bind('click', cancelRerunCourse); + $('.cancel-button').bind('click', cancelRerunCourse); + + CreateCourseUtils.configureHandlers(); + }; + + domReady(onReady); + + // Return these functions so that they can be tested + return { + saveRerunCourse: saveRerunCourse, + cancelRerunCourse: cancelRerunCourse, + onReady: onReady + }; + }); diff --git a/cms/static/js/views/modals/course_outline_modals.js b/cms/static/js/views/modals/course_outline_modals.js new file mode 100644 index 0000000000..e46cd710de --- /dev/null +++ b/cms/static/js/views/modals/course_outline_modals.js @@ -0,0 +1,379 @@ +/** + * The CourseOutlineXBlockModal 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/baseview', + 'js/views/modals/base_modal', 'date', 'js/views/utils/xblock_utils', + 'js/utils/date_utils' +], function( + $, Backbone, _, gettext, BaseView, BaseModal, date, XBlockViewUtils, DateUtils +) { + 'use strict'; + var CourseOutlineXBlockModal, SettingsXBlockModal, PublishXBlockModal, AbstractEditor, BaseDateEditor, + ReleaseDateEditor, DueDateEditor, GradingEditor, PublishEditor, StaffLockEditor; + + CourseOutlineXBlockModal = BaseModal.extend({ + events : { + 'click .action-save': 'save' + }, + + options: $.extend({}, BaseModal.prototype.options, { + modalName: 'course-outline', + modalType: 'edit-settings', + addSaveButton: true, + modalSize: 'med', + viewSpecificClasses: 'confirm', + editors: [] + }), + + initialize: function() { + BaseModal.prototype.initialize.call(this); + this.events = $.extend({}, BaseModal.prototype.events, this.events); + this.template = this.loadTemplate('course-outline-modal'); + this.options.title = this.getTitle(); + }, + + afterRender: function () { + BaseModal.prototype.afterRender.call(this); + this.initializeEditors(); + }, + + initializeEditors: function () { + this.options.editors = _.map(this.options.editors, function (Editor) { + return new Editor({ + parentElement: this.$('.modal-section'), + model: this.model, + xblockType: this.options.xblockType + }); + }, this); + }, + + getTitle: function () { + return ''; + }, + + getIntroductionMessage: function () { + return ''; + }, + + getContentHtml: function() { + return this.template(this.getContext()); + }, + + save: function(event) { + event.preventDefault(); + var requestData = this.getRequestData(); + if (!_.isEqual(requestData, { metadata: {} })) { + XBlockViewUtils.updateXBlockFields(this.model, requestData, { + success: this.options.onSave + }); + } + this.hide(); + }, + + /** + * Return context for the modal. + * @return {Object} + */ + getContext: function () { + return $.extend({ + xblockInfo: this.model, + introductionMessage: this.getIntroductionMessage() + }); + }, + + /** + * Return request data. + * @return {Object} + */ + getRequestData: function () { + var requestData = _.map(this.options.editors, function (editor) { + return editor.getRequestData(); + }); + + return $.extend.apply(this, [true, {}].concat(requestData)); + } + }); + + SettingsXBlockModal = CourseOutlineXBlockModal.extend({ + getTitle: function () { + return interpolate( + gettext('%(display_name)s Settings'), + { display_name: this.model.get('display_name') }, true + ); + }, + + getIntroductionMessage: function () { + return interpolate( + gettext('Change the settings for %(display_name)s'), + { display_name: this.model.get('display_name') }, true + ); + } + }); + + + PublishXBlockModal = CourseOutlineXBlockModal.extend({ + events : { + 'click .action-publish': 'save' + }, + + initialize: function() { + CourseOutlineXBlockModal.prototype.initialize.call(this); + if (this.options.xblockType) { + this.options.modalName = 'bulkpublish-' + this.options.xblockType; + } + }, + + getTitle: function () { + return interpolate( + gettext('Publish %(display_name)s'), + { display_name: this.model.get('display_name') }, true + ); + }, + + getIntroductionMessage: function () { + return interpolate( + gettext('Publish all unpublished changes for this %(item)s?'), + { item: this.options.xblockType }, true + ); + }, + + addActionButtons: function() { + this.addActionButton('publish', gettext('Publish'), true); + this.addActionButton('cancel', gettext('Cancel')); + } + }); + + + AbstractEditor = BaseView.extend({ + tagName: 'section', + templateName: null, + initialize: function() { + this.template = this.loadTemplate(this.templateName); + this.parentElement = this.options.parentElement; + this.render(); + }, + + render: function () { + var html = this.template($.extend({}, { + xblockInfo: this.model, + xblockType: this.options.xblockType + }, this.getContext())); + + this.$el.html(html); + this.parentElement.append(this.$el); + }, + + getContext: function () { + return {}; + }, + + getRequestData: function () { + return {}; + } + }); + + BaseDateEditor = AbstractEditor.extend({ + // Attribute name in the model, should be defined in children classes. + fieldName: null, + + events : { + 'click .clear-date': 'clearValue' + }, + + afterRender: function () { + AbstractEditor.prototype.afterRender.call(this); + 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) + ); + } + } + }); + + DueDateEditor = BaseDateEditor.extend({ + fieldName: 'due', + templateName: 'due-date-editor', + className: 'modal-section-content has-actions due-date-input grading-due-date', + + getValue: function () { + return DateUtils.getDate(this.$('#due_date'), this.$('#due_time')); + }, + + clearValue: function (event) { + event.preventDefault(); + this.$('#due_time, #due_date').val(''); + }, + + getRequestData: function () { + return { + metadata: { + 'due': this.getValue() + } + }; + } + }); + + ReleaseDateEditor = BaseDateEditor.extend({ + fieldName: 'start', + templateName: 'release-date-editor', + className: 'edit-settings-release scheduled-date-input', + startingReleaseDate: null, + + afterRender: function () { + BaseDateEditor.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(''); + }, + + getRequestData: function () { + var newReleaseDate = this.getValue(); + if (JSON.stringify(newReleaseDate) === JSON.stringify(this.startingReleaseDate)) { + return {}; + } + return { + metadata: { + 'start': newReleaseDate + } + }; + } + }); + + GradingEditor = AbstractEditor.extend({ + templateName: 'grading-editor', + className: 'edit-settings-grading', + + afterRender: function () { + AbstractEditor.prototype.afterRender.call(this); + 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')) + }; + } + }); + + PublishEditor = AbstractEditor.extend({ + templateName: 'publish-editor', + className: 'edit-settings-publish', + getRequestData: function () { + return { + publish: 'make_public' + }; + } + }); + + StaffLockEditor = AbstractEditor.extend({ + templateName: 'staff-lock-editor', + className: 'edit-staff-lock', + isModelLocked: function() { + return this.model.get('has_explicit_staff_lock'); + }, + + isAncestorLocked: function() { + return this.model.get('ancestor_has_staff_lock'); + }, + + afterRender: function () { + AbstractEditor.prototype.afterRender.call(this); + this.setLock(this.isModelLocked()); + }, + + setLock: function(value) { + this.$('#staff_lock').prop('checked', value); + }, + + isLocked: function() { + return this.$('#staff_lock').is(':checked'); + }, + + hasChanges: function() { + return this.isModelLocked() != this.isLocked(); + }, + + getRequestData: function() { + return this.hasChanges() ? { + publish: 'republish', + metadata: { + visible_to_staff_only: this.isLocked() ? true : null + } + } : {}; + }, + + getContext: function () { + return { + hasExplicitStaffLock: this.isModelLocked(), + ancestorLocked: this.isAncestorLocked() + } + } + }); + + return { + getModal: function (type, xblockInfo, options) { + if (type === 'edit') { + return this.getEditModal(xblockInfo, options); + } else if (type === 'publish') { + return this.getPublishModal(xblockInfo, options); + } + }, + + getEditModal: function (xblockInfo, options) { + var editors = []; + + if (xblockInfo.isChapter()) { + editors = [ReleaseDateEditor, StaffLockEditor]; + } else if (xblockInfo.isSequential()) { + editors = [ReleaseDateEditor, GradingEditor, DueDateEditor, StaffLockEditor]; + } else if (xblockInfo.isVertical()) { + editors = [StaffLockEditor]; + } + + return new SettingsXBlockModal($.extend({ + editors: editors, + model: xblockInfo + }, options)); + }, + + getPublishModal: function (xblockInfo, options) { + return new PublishXBlockModal($.extend({ + editors: [PublishEditor], + model: xblockInfo + }, options)); + } + }; +}); diff --git a/cms/static/js/views/modals/edit_outline_item.js b/cms/static/js/views/modals/edit_outline_item.js deleted file mode 100644 index 99e76c9491..0000000000 --- a/cms/static/js/views/modals/edit_outline_item.js +++ /dev/null @@ -1,244 +0,0 @@ -/** - * 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; - }); diff --git a/cms/static/js/views/modals/edit_xblock.js b/cms/static/js/views/modals/edit_xblock.js index 99698535d3..67e9de6f88 100644 --- a/cms/static/js/views/modals/edit_xblock.js +++ b/cms/static/js/views/modals/edit_xblock.js @@ -65,15 +65,10 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie onDisplayXBlock: function() { var editorView = this.editorView, - title = this.getTitle(), - xblock = editorView.xblock, - runtime = xblock.runtime; + title = this.getTitle(); // Notify the runtime that the modal has been shown - if (runtime) { - this.runtime = runtime; - runtime.notify('modal-shown', this); - } + editorView.notifyRuntime('modal-shown', this); // Update the modal's header if (editorView.hasCustomTabs()) { @@ -93,7 +88,7 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie // If the xblock is not using custom buttons then choose which buttons to show if (!editorView.hasCustomButtons()) { // If the xblock does not support save then disable the save button - if (!xblock.save) { + if (!editorView.xblock.save) { this.disableSave(); } this.getActionBar().show(); @@ -175,9 +170,7 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie BaseModal.prototype.hide.call(this); // Notify the runtime that the modal has been hidden - if (this.runtime) { - this.runtime.notify('modal-hidden'); - } + this.editorView.notifyRuntime('modal-hidden'); }, findXBlockInfo: function(xblockWrapperElement, defaultXBlockInfo) { diff --git a/cms/static/js/views/overview.js b/cms/static/js/views/overview.js index f33f0c908f..f83d46ee9e 100644 --- a/cms/static/js/views/overview.js +++ b/cms/static/js/views/overview.js @@ -1,7 +1,7 @@ define(["domReady", "jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notification", - "js/utils/cancel_on_escape", "js/utils/date_utils", "js/utils/module"], + "js/utils/cancel_on_escape", "js/utils/date_utils", "js/utils/module", "js/views/utils/view_utils"], function (domReady, $, ui, _, gettext, NotificationView, CancelOnEscape, - DateUtils, ModuleUtils) { + DateUtils, ModuleUtils, ViewUtils) { var modalSelector = '.edit-section-publish-settings'; @@ -222,6 +222,10 @@ define(["domReady", "jquery", "jquery.ui", "underscore", "gettext", "js/views/fe $('.toggle-button-sections').bind('click', toggleSections); $('.expand-collapse').bind('click', toggleSubmodules); + $('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function () { + $('.wrapper-alert-announcement').remove(); + })); + var $body = $('body'); $body.on('click', '.section-published-date .edit-release-date', editSectionPublishDate); $body.on('click', '.edit-section-publish-settings .action-save', saveSetSectionScheduleDate); diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index 257b76fe15..bce220ee2b 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -9,6 +9,7 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views function ($, _, gettext, BasePage, ViewUtils, ContainerView, XBlockView, AddXBlockComponent, EditXBlockModal, XBlockInfo, XBlockStringFieldEditor, ContainerSubviews, UnitOutlineView, XBlockUtils) { + 'use strict'; var XBlockContainerPage = BasePage.extend({ // takes XBlockInfo as a model @@ -88,14 +89,22 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views // Render the xblock xblockView.render({ - success: function() { - xblockView.xblock.runtime.notify("page-shown", self); + done: function() { + // Show the xblock and hide the loading indicator xblockView.$el.removeClass(hiddenCss); - self.renderAddXBlockComponents(); - self.onXBlockRefresh(xblockView); - self.refreshDisplayName(); loadingElement.addClass(hiddenCss); + + // Notify the runtime that the page has been successfully shown + xblockView.notifyRuntime('page-shown', self); + + // Render the add buttons + self.renderAddXBlockComponents(); + + // Refresh the views now that the xblock is visible + self.onXBlockRefresh(xblockView); unitLocationTree.removeClass(hiddenCss); + + // Re-enable Backbone events for any updated DOM elements self.delegateEvents(); } }); @@ -109,11 +118,6 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views return this.xblockView.model.urlRoot; }, - 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(); @@ -159,6 +163,10 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views }); }, + createPlaceholderElement: function() { + return $("
", { class: "studio-xblock-wrapper" }); + }, + createComponent: function(template, target) { // A placeholder element is created in the correct location for the new xblock // and then onNewXBlock will replace it with a rendering of the xblock. Note that @@ -168,7 +176,7 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views buttonPanel = target.closest('.add-xblock-component'), listPanel = buttonPanel.prev(), scrollOffset = ViewUtils.getScrollOffset(buttonPanel), - placeholderElement = $('
').appendTo(listPanel), + placeholderElement = this.createPlaceholderElement().appendTo(listPanel), requestData = _.extend(template, { parent_locator: parentLocator }); @@ -189,7 +197,7 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views ViewUtils.runOperationShowingMessage(gettext('Duplicating…'), function() { var scrollOffset = ViewUtils.getScrollOffset(xblockElement), - placeholderElement = $('
').insertAfter(xblockElement), + placeholderElement = self.createPlaceholderElement().insertAfter(xblockElement), parentElement = self.findXBlockElement(parent), requestData = { duplicate_source_locator: xblockElement.data('locator'), @@ -217,10 +225,13 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views onDelete: function(xblockElement) { // get the parent so we can remove this component from its parent. var xblockView = this.xblockView, - xblock = xblockView.xblock, parent = this.findXBlockElement(xblockElement.parent()); xblockElement.remove(); - xblock.runtime.notify('deleted-child', parent.data('locator')); + + // Inform the runtime that the child has been deleted in case + // other views are listening to deletion events. + xblockView.notifyRuntime('deleted-child', parent.data('locator')); + // Update publish and last modified information from the server. this.model.fetch(); }, diff --git a/cms/static/js/views/pages/container_subviews.js b/cms/static/js/views/pages/container_subviews.js index 5127c755dd..1957633f3b 100644 --- a/cms/static/js/views/pages/container_subviews.js +++ b/cms/static/js/views/pages/container_subviews.js @@ -100,7 +100,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/ onSync: function(model) { if (ViewUtils.hasChangedAttributes(model, [ - 'has_changes', 'published', 'edited_on', 'edited_by', 'visibility_state' + 'has_changes', 'published', 'edited_on', 'edited_by', 'visibility_state', 'has_explicit_staff_lock' ])) { this.render(); } @@ -118,7 +118,9 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/ 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') + releaseDateFrom: this.model.get('release_date_from'), + hasExplicitStaffLock: this.model.get('has_explicit_staff_lock'), + staffLockFrom: this.model.get('staff_lock_from') })); return this; @@ -161,12 +163,13 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/ }, toggleStaffLock: function (e) { - var xblockInfo = this.model, self=this, enableStaffLock, + var xblockInfo = this.model, self=this, enableStaffLock, hasInheritedStaffLock, saveAndPublishStaffLock, revertCheckBox; if (e && e.preventDefault) { e.preventDefault(); } - enableStaffLock = xblockInfo.get('visibility_state') !== VisibilityState.staffOnly; + enableStaffLock = !xblockInfo.get('has_explicit_staff_lock'); + hasInheritedStaffLock = xblockInfo.get('ancestor_has_staff_lock'); revertCheckBox = function() { self.checkStaffLock(!enableStaffLock); @@ -189,8 +192,14 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/ }; this.checkStaffLock(enableStaffLock); - if (enableStaffLock) { - ViewUtils.runOperationShowingMessage(gettext('Hiding Unit from Students…'), + if (enableStaffLock && !hasInheritedStaffLock) { + ViewUtils.runOperationShowingMessage(gettext('Hiding from Students…'), + _.bind(saveAndPublishStaffLock, self)); + } else if (enableStaffLock && hasInheritedStaffLock) { + ViewUtils.runOperationShowingMessage(gettext('Explicitly Hiding from Students…'), + _.bind(saveAndPublishStaffLock, self)); + } else if (!enableStaffLock && hasInheritedStaffLock) { + ViewUtils.runOperationShowingMessage(gettext('Inheriting Student Visibility…'), _.bind(saveAndPublishStaffLock, self)); } else { ViewUtils.confirmThenRunOperation(gettext("Make Visible to Students"), diff --git a/cms/static/js/views/pages/course_outline.js b/cms/static/js/views/pages/course_outline.js index a227642945..a812eb7b02 100644 --- a/cms/static/js/views/pages/course_outline.js +++ b/cms/static/js/views/pages/course_outline.js @@ -2,8 +2,8 @@ * 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) { + "js/views/course_outline", "js/views/utils/view_utils"], + function ($, _, gettext, BasePage, XBlockViewUtils, CourseOutlineView, ViewUtils) { var expandedLocators, CourseOutlinePage; CourseOutlinePage = BasePage.extend({ @@ -25,6 +25,9 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views self.outlineView.handleAddEvent(event); }); this.model.on('change', this.setCollapseExpandVisibility, this); + $('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function () { + $('.wrapper-alert-announcement').removeClass('is-shown').addClass('is-hidden') + })); }, setCollapseExpandVisibility: function() { diff --git a/cms/static/js/views/utils/create_course_utils.js b/cms/static/js/views/utils/create_course_utils.js new file mode 100644 index 0000000000..2c0c3493ac --- /dev/null +++ b/cms/static/js/views/utils/create_course_utils.js @@ -0,0 +1,151 @@ +/** + * Provides utilities for validating courses during creation, for both new courses and reruns. + */ +define(["jquery", "underscore", "gettext", "js/views/utils/view_utils"], + function ($, _, gettext, ViewUtils) { + return function (selectors, classes) { + var validateRequiredField, validateCourseItemEncoding, validateTotalCourseItemsLength, setNewCourseFieldInErr, + hasInvalidRequiredFields, createCourse, validateFilledFields, configureHandlers; + + validateRequiredField = function (msg) { + return msg.length === 0 ? gettext('Required field.') : ''; + }; + + // Check that a course (org, number, run) doesn't use any special characters + validateCourseItemEncoding = function (item) { + var required = validateRequiredField(item); + if (required) { + return required; + } + if ($(selectors.allowUnicode).val() === 'True') { + if (/\s/g.test(item)) { + return gettext('Please do not use any spaces in this field.'); + } + } + else { + if (item !== encodeURIComponent(item)) { + return gettext('Please do not use any spaces or special characters in this field.'); + } + } + return ''; + }; + + // Ensure that org/course_num/run < 65 chars. + validateTotalCourseItemsLength = function () { + var totalLength = _.reduce( + [selectors.org, selectors.number, selectors.run], + function (sum, ele) { + return sum + $(ele).val().length; + }, 0 + ); + if (totalLength > 65) { + $(selectors.errorWrapper).addClass(classes.shown).removeClass(classes.hiding); + $(selectors.errorMessage).html('

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

'); + $(selectors.save).addClass(classes.disabled); + } + else { + $(selectors.errorWrapper).removeClass(classes.shown).addClass(classes.hiding); + } + }; + + setNewCourseFieldInErr = function (el, msg) { + if (msg) { + el.addClass(classes.error); + el.children(selectors.tipError).addClass(classes.showing).removeClass(classes.hiding).text(msg); + $(selectors.save).addClass(classes.disabled); + } + else { + el.removeClass(classes.error); + el.children(selectors.tipError).addClass(classes.hiding).removeClass(classes.showing); + // One "error" div is always present, but hidden or shown + if ($(selectors.error).length === 1) { + $(selectors.save).removeClass(classes.disabled); + } + } + }; + + // One final check for empty values + hasInvalidRequiredFields = function () { + return _.reduce( + [selectors.name, selectors.org, selectors.number, selectors.run], + function (acc, ele) { + var $ele = $(ele); + var error = validateRequiredField($ele.val()); + setNewCourseFieldInErr($ele.parent(), error); + return error ? true : acc; + }, + false + ); + }; + + createCourse = function (courseInfo, errorHandler) { + $.postJSON( + '/course/', + courseInfo, + function (data) { + if (data.url !== undefined) { + ViewUtils.redirect(data.url); + } else if (data.ErrMsg !== undefined) { + errorHandler(data.ErrMsg); + } + } + ); + }; + + // Ensure that all fields are not empty + validateFilledFields = function () { + return _.reduce( + [selectors.org, selectors.number, selectors.run, selectors.name], + function (acc, ele) { + var $ele = $(ele); + return $ele.val().length !== 0 ? acc : false; + }, + true + ); + }; + + // Handle validation asynchronously + configureHandlers = function () { + _.each( + [selectors.org, selectors.number, selectors.run], + function (ele) { + var $ele = $(ele); + $ele.on('keyup', function (event) { + // Don't bother showing "required field" error when + // the user tabs into a new field; this is distracting + // and unnecessary + if (event.keyCode === 9) { + return; + } + var error = validateCourseItemEncoding($ele.val()); + setNewCourseFieldInErr($ele.parent(), error); + validateTotalCourseItemsLength(); + if (!validateFilledFields()) { + $(selectors.save).addClass(classes.disabled); + } + }); + } + ); + var $name = $(selectors.name); + $name.on('keyup', function () { + var error = validateRequiredField($name.val()); + setNewCourseFieldInErr($name.parent(), error); + validateTotalCourseItemsLength(); + if (!validateFilledFields()) { + $(selectors.save).addClass(classes.disabled); + } + }); + }; + + return { + validateRequiredField: validateRequiredField, + validateCourseItemEncoding: validateCourseItemEncoding, + validateTotalCourseItemsLength: validateTotalCourseItemsLength, + setNewCourseFieldInErr: setNewCourseFieldInErr, + hasInvalidRequiredFields: hasInvalidRequiredFields, + createCourse: createCourse, + validateFilledFields: validateFilledFields, + configureHandlers: configureHandlers + }; + }; + }); diff --git a/cms/static/js/views/utils/view_utils.js b/cms/static/js/views/utils/view_utils.js index 98eab24d96..27d969f523 100644 --- a/cms/static/js/views/utils/view_utils.js +++ b/cms/static/js/views/utils/view_utils.js @@ -5,7 +5,7 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js function ($, _, gettext, NotificationView, PromptView) { var toggleExpandCollapse, showLoadingIndicator, hideLoadingIndicator, confirmThenRunOperation, runOperationShowingMessage, disableElementWhileRunning, getScrollOffset, setScrollOffset, - setScrollTop, redirect, hasChangedAttributes; + setScrollTop, redirect, reload, hasChangedAttributes, deleteNotificationHandler; /** * Toggles the expanded state of the current element. @@ -94,6 +94,21 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js }); }; + /** + * Returns a handler that removes a notification, both dismissing it and deleting it from the database. + * @param callback function to call when deletion succeeds + */ + deleteNotificationHandler = function(callback) { + return function (event) { + event.preventDefault(); + $.ajax({ + url: $(this).data('dismiss-link'), + type: 'DELETE', + success: callback + }); + }; + }; + /** * Performs an animated scroll so that the window has the specified scroll top. * @param scrollTop The desired scroll top for the window. @@ -132,6 +147,13 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js window.location = url; }; + /** + * Reloads the page. This is broken out as its own function for unit testing. + */ + reload = function() { + window.location.reload(); + }; + /** * Returns true if a model has changes to at least one of the specified attributes. * @param model The model in question. @@ -158,10 +180,12 @@ define(["jquery", "underscore", "gettext", "js/views/feedback_notification", "js 'confirmThenRunOperation': confirmThenRunOperation, 'runOperationShowingMessage': runOperationShowingMessage, 'disableElementWhileRunning': disableElementWhileRunning, + 'deleteNotificationHandler': deleteNotificationHandler, 'setScrollTop': setScrollTop, 'getScrollOffset': getScrollOffset, 'setScrollOffset': setScrollOffset, 'redirect': redirect, + 'reload': reload, 'hasChangedAttributes': hasChangedAttributes }; }); diff --git a/cms/static/js/views/utils/xblock_utils.js b/cms/static/js/views/utils/xblock_utils.js index fe1bb8971a..3fe68f90bd 100644 --- a/cms/static/js/views/utils/xblock_utils.js +++ b/cms/static/js/views/utils/xblock_utils.js @@ -165,6 +165,18 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils", "js/util return listType; }; + 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; + }; + return { 'VisibilityState': VisibilityState, 'addXBlock': addXBlock, @@ -172,6 +184,7 @@ define(["jquery", "underscore", "gettext", "js/views/utils/view_utils", "js/util 'updateXBlockField': updateXBlockField, 'getXBlockVisibilityClass': getXBlockVisibilityClass, 'getXBlockListTypeClass': getXBlockListTypeClass, - 'updateXBlockFields': updateXBlockFields + 'updateXBlockFields': updateXBlockFields, + 'getXBlockType': getXBlockType }; }); diff --git a/cms/static/js/views/xblock.js b/cms/static/js/views/xblock.js index 48906f0b58..78d1f08b84 100644 --- a/cms/static/js/views/xblock.js +++ b/cms/static/js/views/xblock.js @@ -29,34 +29,58 @@ define(["jquery", "underscore", "js/views/baseview", "xblock/runtime.v1"], var self = this, wrapper = this.$el, xblockElement, - success = options ? options.success : null, + successCallback = options ? options.success || options.done : null, + errorCallback = options ? options.error || options.done : null, xblock, fragmentsRendered; fragmentsRendered = this.renderXBlockFragment(fragment, wrapper); - fragmentsRendered.done(function() { + fragmentsRendered.always(function() { xblockElement = self.$('.xblock').first(); - xblock = XBlock.initializeBlock(xblockElement); - self.xblock = xblock; - self.xblockReady(xblock); - if (success) { - success(xblock); + try { + xblock = XBlock.initializeBlock(xblockElement); + self.xblock = xblock; + self.xblockReady(xblock); + if (successCallback) { + successCallback(xblock); + } + } catch (e) { + console.error(e.stack); + // Add 'xblock-initialization-failed' class to every xblock + self.$('.xblock').addClass('xblock-initialization-failed'); + + // If the xblock was rendered but failed then still call xblockReady to allow + // drag-and-drop to be initialized. + if (xblockElement) { + self.xblockReady(null); + } + if (errorCallback) { + errorCallback(); + } } }); }, /** - * This method is called upon successful rendering of an xblock. + * Sends a notification event to the runtime, if one is available. Note that the runtime + * is only available once the xblock has been rendered and successfully initialized. + * @param eventName The name of the event to be fired. + * @param data The data to be passed to any listener's of the event. */ - xblockReady: function(xblock) { - // Do nothing + notifyRuntime: function(eventName, data) { + var runtime = this.xblock && this.xblock.runtime; + if (runtime) { + runtime.notify(eventName, data); + } }, /** - * Returns true if the specified xblock has children. + * This method is called upon successful rendering of an xblock. Note that the xblock + * may have thrown JavaScript errors after rendering in which case the xblock parameter + * will be null. */ - hasChildXBlocks: function() { - return this.$('.wrapper-xblock').length > 0; + xblockReady: function(xblock) { + // Do nothing }, /** @@ -77,9 +101,16 @@ define(["jquery", "underscore", "js/views/baseview", "xblock/runtime.v1"], } // Render the HTML first as the scripts might depend upon it, and then - // asynchronously add the resources to the page. - this.updateHtml(element, html); - return this.addXBlockFragmentResources(resources); + // asynchronously add the resources to the page. Any errors that are thrown + // by included scripts are logged to the console but are then ignored assuming + // that at least the rendered HTML will be in place. + try { + this.updateHtml(element, html); + return this.addXBlockFragmentResources(resources); + } catch(e) { + console.error(e.stack); + return $.Deferred().resolve(); + } }, /** @@ -106,7 +137,7 @@ define(["jquery", "underscore", "js/views/baseview", "xblock/runtime.v1"], numResources = resources.length; deferred = $.Deferred(); applyResource = function(index) { - var hash, resource, head, value, promise; + var hash, resource, value, promise; if (index >= numResources) { deferred.resolve(); return; diff --git a/cms/static/js/views/xblock_outline.js b/cms/static/js/views/xblock_outline.js index 15fafffb86..8d92da1dc9 100644 --- a/cms/static/js/views/xblock_outline.js +++ b/cms/static/js/views/xblock_outline.js @@ -57,9 +57,9 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/ 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, + xblockType = XBlockViewUtils.getXBlockType(this.model.get('category'), this.parentInfo), + xblockTypeDisplayName = XBlockViewUtils.getXBlockType(this.model.get('category'), this.parentInfo, true), + parentType = parentInfo ? XBlockViewUtils.getXBlockType(parentInfo.get('category')) : null, addChildName = null, defaultNewChildName = null, html, @@ -78,12 +78,14 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/ xblockType: xblockType, xblockTypeDisplayName: xblockTypeDisplayName, parentType: parentType, - childType: childInfo ? this.getXBlockType(childInfo.category, xblockInfo) : null, + childType: childInfo ? XBlockViewUtils.getXBlockType(childInfo.category, xblockInfo) : null, childCategory: childInfo ? childInfo.category : null, addChildLabel: addChildName, defaultNewChildName: defaultNewChildName, isCollapsed: isCollapsed, - includesChildren: this.shouldRenderChildren() + includesChildren: this.shouldRenderChildren(), + hasExplicitStaffLock: this.model.get('has_explicit_staff_lock'), + staffOnlyMessage: this.model.get('staff_only_message') }); if (this.parentInfo) { this.setElement($(html)); @@ -147,10 +149,17 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/ } } // Ensure that the children have been rendered before expanding - if (this.shouldRenderChildren() && !this.renderedChildren) { + this.ensureChildrenRendered(); + BaseView.prototype.toggleExpandCollapse.call(this, event); + }, + + /** + * Verifies that the children are rendered (if they should be). + */ + ensureChildrenRendered: function() { + if (!this.renderedChildren && this.shouldRenderChildren()) { this.renderChildren(); } - BaseView.prototype.toggleExpandCollapse.call(this, event); }, /** @@ -184,18 +193,6 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/ }); }, - 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(); @@ -266,7 +263,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/ var self = this, parentView = this.parentView; event.preventDefault(); - var xblockType = this.getXBlockType(this.model.get('category'), parentView.model, true); + var xblockType = XBlockViewUtils.getXBlockType(this.model.get('category'), parentView.model, true); XBlockViewUtils.deleteXBlock(this.model, xblockType).done(function() { if (parentView) { parentView.onChildDeleted(self, event); diff --git a/cms/static/sass/_base.scss b/cms/static/sass/_base.scss index b1b57f5ff0..547d9b2e39 100644 --- a/cms/static/sass/_base.scss +++ b/cms/static/sass/_base.scss @@ -240,6 +240,282 @@ 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; + } + } + } + + // CASE: wizard-based mast + .mast-wizard { + + .page-header-sub { + @extend %t-title4; + color: $gray; + font-weight: 300; + } + + .page-header-super { + @extend %t-title4; + float: left; + width: flex-grid(12,12); + margin-top: ($baseline/2); + border-top: 1px solid $gray-l4; + padding-top: ($baseline/2); + font-weight: 600; + } + } + + // 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; +} // ==================== diff --git a/cms/static/sass/_shame.scss b/cms/static/sass/_shame.scss index 6d0ceb35a8..1be5d2a836 100644 --- a/cms/static/sass/_shame.scss +++ b/cms/static/sass/_shame.scss @@ -107,6 +107,23 @@ div.wrapper-comp-editor.is-inactive ~ div.launch-latex-compiler{ // ==================== +// JQuery UI tabs font-weight override +.ui-tabs-nav { + + .ui-state-default { + font-weight: normal; + } +} + +// ==================== + +// xmodule editor tab font-weight override +.xmodule_edit.xmodule_VideoDescriptor .editor-with-tabs .editor-tabs .inner_tab_wrap a.tab { + font-weight: normal !important; +} + +// ==================== + // TODOs: // * font-weight syncing diff --git a/cms/static/sass/_variables.scss b/cms/static/sass/_variables.scss index 84cd91407b..c046742721 100644 --- a/cms/static/sass/_variables.scss +++ b/cms/static/sass/_variables.scss @@ -170,6 +170,7 @@ $color-staff-only: $black; $color-heading-base: $gray-d2; $color-copy-base: $gray-l1; +$color-copy-emphasized: $gray-d2; // ==================== diff --git a/cms/static/sass/elements/_controls.scss b/cms/static/sass/elements/_controls.scss index 16434c5410..f5c9eb7434 100644 --- a/cms/static/sass/elements/_controls.scss +++ b/cms/static/sass/elements/_controls.scss @@ -133,6 +133,27 @@ } } +// white secondary button +%btn-secondary-white { + @extend %ui-btn-secondary; + border-color: $white-t2; + color: $white-t3; + + &:hover, &:active { + border-color: $white; + color: $white; + } + + &.current, &.active { + background: $gray-d2; + color: $gray-l5; + + &:hover, &:active { + background: $gray-d2; + } + } +} + // green secondary button %btn-secondary-green { @extend %ui-btn-secondary; @@ -213,17 +234,6 @@ // ==================== -// calls-to-action - -// ==================== - -// specific buttons - view live -%view-live-button { - @extend %t-action4; -} - -// ==================== - // UI: element actions list %actions-list { diff --git a/cms/static/sass/elements/_forms.scss b/cms/static/sass/elements/_forms.scss index b9d486451b..d0152bdd40 100644 --- a/cms/static/sass/elements/_forms.scss +++ b/cms/static/sass/elements/_forms.scss @@ -9,13 +9,14 @@ textarea.text { @include box-sizing(border-box); @include linear-gradient($gray-l5, $white); @extend %t-copy-sub2; + @extend %t-demi-strong; padding: 6px 8px 8px; border: 1px solid $gray-l2; border-radius: 2px; background-color: $gray-l5; box-shadow: inset 0 1px 2px $shadow-l1; font-family: 'Open Sans', sans-serif; - color: $baseFontColor; + color: $color-copy-emphasized; outline: 0; &::-webkit-input-placeholder, @@ -59,6 +60,46 @@ textarea.text { // forms - additional UI form { + // CASE: cosmetic checkbox input + .checkbox-cosmetic { + + .input-checkbox-checked, .input-checkbox-unchecked, .label { + display: inline-block; + vertical-align: middle; + } + + .input-checkbox-checked, .input-checkbox-unchecked { + width: $baseline; + } + + .input-checkbox { + @extend %cont-text-sr; + + // CASE: unchecked + ~ label .input-checkbox-checked { + display: none; + } + + ~ label .input-checkbox-unchecked { + display: inline-block; + } + + // CASE: checked + &:checked { + + ~ label .input-checkbox-checked { + display: inline-block; + } + + ~ label .input-checkbox-unchecked { + display: none; + } + } + + } + } + + // CASE: file input input[type=file] { @extend %t-copy-sub1; } @@ -97,28 +138,10 @@ form { } } -// ELEM: form wrapper -.wrapper-create-element { - height: 0; - margin-bottom: $baseline; - opacity: 0.0; - pointer-events: none; - overflow: hidden; - - &.animate { - @include transition(opacity $tmg-f1 ease-in-out 0s, height $tmg-f1 ease-in-out 0s); - } - - &.is-shown { - height: auto; // define a specific height for the animating version of this UI to work properly - opacity: 1.0; - pointer-events: auto; - } -} - // ELEM: form // form styling for creating a new content item (course, user, textbook) -form[class^="create-"] { +// TODO: refactor this into a placeholder to extend. +.form-create { @extend %ui-window; .title { @@ -213,12 +236,19 @@ form[class^="create-"] { .tip { @extend %t-copy-sub2; - @include transition(color, 0.15s, ease-in-out); + @include transition(color 0.15s ease-in-out); + + display: block; margin-top: ($baseline/4); color: $gray-l3; } + .tip-note { + display: block; + margin-top: ($baseline/4); + } + .tip-error { display: none; float: none; @@ -325,7 +355,6 @@ form[class^="create-"] { } } - // form - inline xblock name edit on unit, container, outline // TOOD: abstract this out into a Sass placeholder @@ -362,6 +391,25 @@ form[class^="create-"] { } } +// ELEM: form wrapper +.wrapper-create-element { + height: 0; + margin-bottom: $baseline; + opacity: 0.0; + pointer-events: none; + overflow: hidden; + + &.animate { + @include transition(opacity $tmg-f1 ease-in-out 0s, height $tmg-f1 ease-in-out 0s); + } + + &.is-shown { + height: auto; // define a specific height for the animating version of this UI to work properly + opacity: 1.0; + pointer-events: auto; + } +} + // ==================== // forms - grandfathered diff --git a/cms/static/sass/elements/_layout.scss b/cms/static/sass/elements/_layout.scss index 1dbd1b0b10..cc3324805c 100644 --- a/cms/static/sass/elements/_layout.scss +++ b/cms/static/sass/elements/_layout.scss @@ -141,6 +141,26 @@ } } + // CASE: wizard-based mast + .mast-wizard { + + .page-header-sub { + @extend %t-title4; + color: $gray; + font-weight: 300; + } + + .page-header-super { + @extend %t-title4; + float: left; + width: flex-grid(12,12); + margin-top: ($baseline/2); + border-top: 1px solid $gray-l4; + padding-top: ($baseline/2); + font-weight: 600; + } + } + // page metadata/action bar .metadata { diff --git a/cms/static/sass/elements/_modal-window.scss b/cms/static/sass/elements/_modal-window.scss index 543218c5d8..c80ff86862 100644 --- a/cms/static/sass/elements/_modal-window.scss +++ b/cms/static/sass/elements/_modal-window.scss @@ -25,7 +25,7 @@ .title { @extend %t-title5; - @extend %t-strong; + @extend %t-demi-strong; margin: ($baseline/4) ($baseline/2) ($baseline/2) ($baseline/2); color: $black; } @@ -54,7 +54,7 @@ // sections within a modal .modal-section { - margin-bottom: ($baseline/2); + margin-bottom: ($baseline*0.75); &:last-child { margin-bottom: 0; @@ -234,8 +234,11 @@ } } - // edit outline item settings - .edit-outline-item-modal { + // outline: edit item settings + .wrapper-modal-window-bulkpublish-section, + .wrapper-modal-window-bulkpublish-subsection, + .wrapper-modal-window-bulkpublish-unit, + .course-outline-modal { .list-fields { @@ -245,12 +248,6 @@ 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; @extend %t-strong; @@ -288,6 +285,27 @@ .due-time { width: ($baseline*7); } + + .tip { + @extend %t-copy-sub1; + @include transition(color, 0.15s, ease-in-out); + display: block; + margin-top: ($baseline/4); + color: $gray-l2; + } + + .tip-warning { + color: $gray-d2; + } + } + + // CASE: type-based input + .field-text { + + // TODO: refactor the _forms.scss partial to allow for this area to inherit from it + label, input, textarea { + display: block; + } } // CASE: select input @@ -305,15 +323,52 @@ .input { width: 100%; } + + // CASE: checkbox input + .field-checkbox { + + .label, label { + margin-bottom: 0; + } + } } } + + + // UI: grading section .edit-settings-grading { .grading-type { margin-bottom: $baseline; } } + + // UI: staff lock section + .edit-staff-lock { + + .checkbox-cosmetic .input-checkbox { + @extend %cont-text-sr; + + // CASE: unchecked + ~ .tip-warning { + display: block; + } + + // CASE: checked + &:checked { + + ~ .tip-warning { + display: none; + } + } + } + + // needed to override poorly scoped margin-bottom on any label element in a view (from _forms.scss) + .checkbox-cosmetic .label { + margin-bottom: 0; + } + } } // xblock custom actions diff --git a/cms/static/sass/elements/_modules.scss b/cms/static/sass/elements/_modules.scss index 9894fffcab..183bf29a64 100644 --- a/cms/static/sass/elements/_modules.scss +++ b/cms/static/sass/elements/_modules.scss @@ -198,7 +198,7 @@ a { @include clearfix(); @include transition(none); - @extend %t-strong; + @extend %t-demi-strong; display: block; border: 0px; padding: 7px $baseline; @@ -263,7 +263,6 @@ // outline UI // -------------------- - // outline: utilities $outline-indent-width: $baseline; @@ -294,82 +293,20 @@ $outline-indent-width: $baseline; } -// 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); +%outline-item-status { + @extend %t-copy-sub2; + @extend %t-strong; + color: $color-copy-base; - // 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; - } + .icon { + @extend %t-icon5; + margin-right: ($baseline/4); } } -// 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 { +// outline UI - complex +// -------------------- +%outline-complex-item { // UI: item title .item-title { @@ -424,154 +361,326 @@ $outline-indent-width: $baseline; } } -%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; +// outline UI - simple +// -------------------- +%outline-simple-item { 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; + // CASE: last-child in UI + &:last-child { + margin-bottom: 0; } - // STATE: is-collapsed - &.is-collapsed { + .item-title a { + color: $color-heading-base; + &:hover { + color: $blue; + } } +} - // header - title - .subsection-title { - @extend %t-title6; - color: $color-heading-base; - } - // status - .subsection-status { - @extend %outline-item-status; - } +// CASE: complex outline +.outline-complex { - // STATE: hover/active - &:hover, &:active { + // outline: sections + .outline-section { + @include transition(border-left-width $tmg-f2 linear 0s, border-left-color $tmg-f2 linear 0s, padding-left $tmg-f2 linear 0s); + @extend %ui-window; + @extend %outline-complex-item; + 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; + } + } + + // header - title + .section-title { + @extend %t-title5; + @extend %t-strong; + color: $color-heading-base; + } + + // status + .section-status { + @extend %outline-item-status; + } // status - release - > .subsection-status .status-release { - opacity: 1.0; + .status-release { + @include transition(opacity $tmg-f2 ease-in-out 0s); + opacity: 0.65; + } + + // 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 { + @include transition(border-left-color $tmg-f2 linear 0s); + @extend %outline-complex-item; + 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*0.75); + + + // 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; + } + + // 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; + } } // status - grading - > .subsection-status .status-grading { - opacity: 1.0; + .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); + } + } + + // outline: units + .outline-unit { + @extend %outline-complex-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; + } } } } -// outline: units -.outline-unit { - @extend %outline-item; - margin-bottom: ($baseline/2); - border: 1px solid $gray-l4; - padding: ($baseline/4) ($baseline/2); +// CASE: simple outline +.outline-simple { - // header - title - .unit-title { - @extend %t-title7; - color: $color-heading-base; - } + // outline: sections + .outline-section { + @extend %outline-simple-item; + margin-bottom: $baseline; + padding: ($baseline/2); - .unit-status { - @extend %outline-item-status; - } + // header - title + .section-title { + @extend %t-title5; + @extend %t-strong; + color: $color-heading-base; + } - // STATE: hover/active - &:hover, &:active { - box-shadow: 0 1px 1px $shadow-l2; + // status + .section-status { + @extend %outline-item-status; + } // status - release - .unit-status .status-release { - opacity: 1.0; + .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; + } + } + + // outline: subsections + .outline-subsection { + @extend %outline-simple-item; + margin-bottom: ($baseline/2); + padding: ($baseline/2); + + // header - title + .subsection-title { + @extend %t-title6; + color: $color-heading-base; + } + + // status + .subsection-status { + @extend %outline-item-status; + } + } + + // outline: units + .outline-unit { + @extend %outline-simple-item; + margin-bottom: ($baseline/4); + padding: ($baseline/4) ($baseline/2); + + // header - title + .unit-title { + @extend %t-title7; + color: $color-heading-base; + } + + .unit-status { + @extend %outline-item-status; } } } diff --git a/cms/static/sass/elements/_system-feedback.scss b/cms/static/sass/elements/_system-feedback.scss index 3326ca4427..37c1df0aa6 100644 --- a/cms/static/sass/elements/_system-feedback.scss +++ b/cms/static/sass/elements/_system-feedback.scss @@ -527,7 +527,7 @@ &.wrapper-alert-warning { box-shadow: 0 1px 1px $white, inset 0 2px 2px $shadow-d1, inset 0 -4px 1px $orange; - [class^="icon"] { + .alert-symbol { color: $orange; } } @@ -535,7 +535,7 @@ &.wrapper-alert-error { box-shadow: 0 1px 1px $white, inset 0 2px 2px $shadow-d1, inset 0 -4px 1px $red-l1; - [class^="icon"] { + .alert-symbol { color: $red-l1; } } @@ -543,7 +543,7 @@ &.wrapper-alert-confirmation { box-shadow: 0 1px 1px $white, inset 0 2px 2px $shadow-d1, inset 0 -4px 1px $green; - [class^="icon"] { + .alert-symbol { color: $green; } } @@ -551,7 +551,7 @@ &.wrapper-alert-announcement { box-shadow: 0 1px 1px $white, inset 0 2px 2px $shadow-d1, inset 0 -4px 1px $blue; - [class^="icon"] { + .alert-symbol { color: $blue; } } @@ -559,7 +559,7 @@ &.wrapper-alert-step-required { box-shadow: 0 1px 1px $white, inset 0 2px 2px $shadow-d1, inset 0 -4px 1px $pink; - [class^="icon"] { + .alert-symbol { color: $pink; } } @@ -579,11 +579,11 @@ @extend %t-strong; } - [class^="icon"], .copy { + .alert-symbol, .copy { float: left; } - [class^="icon"] { + .alert-symbol { @include transition (color 0.50s ease-in-out 0s); @extend %t-icon3; width: flex-grid(1, 12); @@ -605,7 +605,7 @@ // with actions &.has-actions { - [class^="icon"] { + .alert-symbol { width: flex-grid(1, 12); } @@ -667,6 +667,29 @@ background: $gray-d1; } } + + // with dismiss (to sunset action-alert-clos) + .action-dismiss { + + .button { + @extend %btn-secondary-white; + padding:($baseline/4) ($baseline/2); + } + + .icon,.button-copy { + display: inline-block; + vertical-align: middle; + } + + .icon { + @extend %t-icon4; + margin-right: ($baseline/4); + } + + .button-copy { + @extend %t-copy-sub1; + } + } } // ==================== diff --git a/cms/static/sass/elements/_system-help.scss b/cms/static/sass/elements/_system-help.scss index f7e34f2043..0538bf6f7d 100644 --- a/cms/static/sass/elements/_system-help.scss +++ b/cms/static/sass/elements/_system-help.scss @@ -243,6 +243,12 @@ } } + // learn more (aka external help button) + .external-help-button { + @extend %ui-btn-flat-outline; + @extend %sizing; + } + // actions .list-actions { @extend %cont-no-list; diff --git a/cms/static/sass/elements/_typography.scss b/cms/static/sass/elements/_typography.scss index 356b8f9517..cb930f3960 100644 --- a/cms/static/sass/elements/_typography.scss +++ b/cms/static/sass/elements/_typography.scss @@ -10,6 +10,9 @@ %t-strong { font-weight: 600; } +%t-demi-strong { + font-weight: 500; +} %t-regular { font-weight: 400; } diff --git a/cms/static/sass/elements/_xblocks.scss b/cms/static/sass/elements/_xblocks.scss index 2054fa928a..c75daf9b94 100644 --- a/cms/static/sass/elements/_xblocks.scss +++ b/cms/static/sass/elements/_xblocks.scss @@ -497,18 +497,21 @@ &:first-child { border-top: 0; } - } - &:hover { - opacity: 1.0; - } - &.is-set { - opacity: 1.0; - background-color: $white; - .setting-input { - color: $blue-l1; + // STATE: hover & focus + &:hover, &:focus { + opacity: 1.0; + } + + &.is-set { + opacity: 1.0; + background-color: $white; + + .setting-input { + color: $blue-l1; + } } } diff --git a/cms/static/sass/style-app-extend1.scss b/cms/static/sass/style-app-extend1.scss index bdcf8f79d1..924b7c114f 100644 --- a/cms/static/sass/style-app-extend1.scss +++ b/cms/static/sass/style-app-extend1.scss @@ -38,6 +38,7 @@ @import 'views/dashboard'; @import 'views/export'; @import 'views/index'; +@import 'views/course-create'; @import 'views/import'; @import 'views/outline'; @import 'views/settings'; diff --git a/cms/static/sass/views/_container.scss b/cms/static/sass/views/_container.scss index 4064a34e0f..7a2163939c 100644 --- a/cms/static/sass/views/_container.scss +++ b/cms/static/sass/views/_container.scss @@ -150,6 +150,10 @@ .user { @extend %t-strong; } + + .user { + @extend %cont-text-wrap; + } } .wrapper-release { @@ -157,6 +161,11 @@ .release-date { @extend %t-strong; } + + .release-with { + @extend %t-title8; + display: block; + } } .wrapper-visibility { @@ -170,6 +179,13 @@ margin-left: ($baseline/4); color: $gray-d1; } + + .inherited-from { + @extend %t-title8; + display: block; + } + + } .wrapper-pub-actions { @@ -213,6 +229,10 @@ .user { @extend %t-strong; } + + .user { + @extend %cont-text-wrap; + } } } @@ -224,8 +244,10 @@ .wrapper-unit-id { .unit-id-value { + @extend %cont-text-wrap; @extend %t-copy-sub1; display: inline-block; + width: 100%; } .tip { @@ -237,29 +259,9 @@ } .wrapper-unit-tree-location { - // tree location-specific styles should go here - .outline-section{ - box-shadow: none; - border: 0; - padding: 0; - } - - .outline-subsection { - border-left: 1px solid $gray-l4; - padding: ($baseline/2); - - .subsection-header { - margin-bottom: ($baseline/2); - } - } - - .item-title a { - color: $color-heading-base; - - &:hover { - color: $blue; - } + .item-title { + @extend %cont-text-wrap; } } } diff --git a/cms/static/sass/views/_course-create.scss b/cms/static/sass/views/_course-create.scss new file mode 100644 index 0000000000..3b6ce18371 --- /dev/null +++ b/cms/static/sass/views/_course-create.scss @@ -0,0 +1,122 @@ +// studio - views - course creation page +// ==================== + +.view-course-create { + + // basic layout + // -------------------- + .content-primary, .content-supplementary { + @include box-sizing(border-box); + float: left; + } + + .content-primary { + width: flex-grid(9, 12); + margin-right: flex-gutter(); + } + + .content-supplementary { + width: flex-grid(3, 12); + } + + // + + // header/masthead + // -------------------- + .mast .page-header-super { + + .course-original-title-id, .course-original-title { + display: block; + } + + .course-original-title-id { + @extend %t-title5; + } + } + + + // course re-run form + // -------------------- + .rerun-course { + + .row { + @include clearfix(); + margin-bottom: ($baseline*0.75); + } + + .column { + float: left; + width: 48%; + } + + .column:first-child { + margin-right: 4%; + } + + label { + @extend %t-title7; + display: block; + font-weight: 700; + } + + .rerun-course-org, + .rerun-course-number, + .rerun-course-name, + .rerun-course-run { + width: 100%; + } + + .rerun-course-name { + @extend %t-title5; + font-weight: 300; + } + + .rerun-course-save { + @include blue-button; + + .icon { + display: inline-block; + vertical-align: middle; + margin-right: ($baseline/4); + } + } + + .rerun-course-cancel { + @include white-button; + } + + .item-details { + padding-bottom: 0; + } + + .wrap-error { + @include transition(opacity $tmg-f2 ease 0s); + opacity: 0; + } + + .wrap-error.is-shown { + opacity: 1; + } + + .message-status { + display: block; + margin-bottom: 0; + padding: ($baseline*.5) ($baseline*1.5) 8px ($baseline*1.5); + font-weight: bold; + } + + // NOTE: override for modern button styling until all buttons (in _forms.scss) can be converted + .actions { + + .action-primary { + @include blue-button; + @extend %t-action2; + } + + .action-secondary { + @include grey-button; + @extend %t-action2; + } + } + } +} diff --git a/cms/static/sass/views/_dashboard.scss b/cms/static/sass/views/_dashboard.scss index 69ba41fd2b..167cdde7bb 100644 --- a/cms/static/sass/views/_dashboard.scss +++ b/cms/static/sass/views/_dashboard.scss @@ -236,7 +236,7 @@ @extend %t-strong; margin: ($baseline/2); - [class^="icon-"] { + .icon { margin-right: ($baseline/4); } } @@ -294,120 +294,307 @@ // ELEM: course listings .courses { margin: $baseline 0; - } + + .title { + @extend %t-title6; + margin-bottom: $baseline; + border-bottom: 1px solid $gray-l3; + padding-bottom: ($baseline/2); + color: $gray-l2; + } + + .title { + @extend %t-title6; + margin-bottom: $baseline; + border-bottom: 1px solid $gray-l3; + padding-bottom: ($baseline/2); + color: $gray-l2; + } + } .list-courses { margin-top: $baseline; border-radius: 3px; - border: 1px solid $gray; + border: 1px solid $gray-l2; background: $white; - box-shadow: 0 1px 2px $shadow-l1; + box-shadow: 0 1px 1px $shadow-l1; - .course-item { - @include box-sizing(border-box); - width: flex-grid(9, 9); - position: relative; - border-bottom: 1px solid $gray-l1; - padding: $baseline; + li:last-child { + margin-bottom: 0; + } + } - // STATE: hover/focus - &:hover { - background: $paleYellow; - .course-actions .view-live-button { - opacity: 1.0; - pointer-events: auto; - } + // UI: course wrappers (needed for status messages) + .wrapper-course { - .course-title { - color: $orange-d1; - } + // CASE: has status + &.has-status { - .course-metadata { - opacity: 1.0; - } - } - - .course-link, .course-actions { + .course-status { @include box-sizing(border-box); display: inline-block; vertical-align: middle; - } - - // encompassing course link - .course-link { - @extend %ui-depth2; - width: flex-grid(7, 9); - margin-right: flex-gutter(); - } - - // course title - .course-title { - @extend %t-title4; - @extend %t-light; - margin: 0 ($baseline*2) ($baseline/4) 0; - } - - // course metadata - .course-metadata { - @extend %t-copy-sub1; - @include transition(opacity $tmg-f1 ease-in-out 0); - color: $gray; - opacity: 0.75; - - .metadata-item { - display: inline-block; - - &:after { - content: "/"; - margin-left: ($baseline/10); - margin-right: ($baseline/10); - color: $gray-l4; - } - - &:last-child { - - &:after { - content: ""; - margin-left: 0; - margin-right: 0; - } - } - - .label { - @extend %cont-text-sr; - } - } - } - - .course-actions { - @extend %ui-depth3; - position: static; - width: flex-grid(2, 9); + width: flex-grid(3, 9); + padding-right: ($baseline/2); text-align: right; - // view live button - .view-live-button { - @extend %ui-depth3; - @include transition(opacity $tmg-f2 ease-in-out 0); - @include box-sizing(border-box); - padding: ($baseline/2); - opacity: 0.0; - pointer-events: none; + .value { - &:hover { - opacity: 1.0; - pointer-events: auto; + .copy, .icon { + display: inline-block; + vertical-align: middle; + } + + .icon { + @extend %t-icon4; + margin-right: ($baseline/2); + } + + .copy { + @extend %t-copy-sub1; } } + } - &:last-child { - border-bottom: none; + .status-message { + @extend %t-copy-sub1; + background-color: $gray-l5; + box-shadow: 0 2px 2px 0 $shadow inset; + padding: ($baseline*0.75) $baseline; + + &.has-actions { + + .copy, .status-actions { + display: inline-block; + vertical-align: middle; + } + + .copy { + width: 65%; + margin: 0 $baseline 0 0; + } + + .status-actions { + width: 30%; + text-align: right; + + .button { + @extend %btn-secondary-white; + padding:($baseline/4) ($baseline/2); + } + + .icon,.button-copy { + display: inline-block; + vertical-align: middle; + } + + .icon { + @extend %t-icon4; + margin-right: ($baseline/4); + } + + .button-copy { + @extend %t-copy-sub1; + } + } } } } } + // UI: individual course listings + .course-item { + @include box-sizing(border-box); + width: flex-grid(9, 9); + position: relative; + border-bottom: 1px solid $gray-l2; + padding: $baseline; + + // STATE: hover/focus + &:hover { + background: $paleYellow; + + .course-actions { + opacity: 1.0; + pointer-events: auto; + } + + .course-title { + color: $orange-d1; + } + + .course-metadata { + opacity: 1.0; + } + } + + .course-link, .course-actions { + @include box-sizing(border-box); + display: inline-block; + vertical-align: middle; + } + + // encompassing course link + .course-link { + @extend %ui-depth2; + width: flex-grid(6, 9); + margin-right: flex-gutter(); + } + + // course title + .course-title { + @extend %t-title4; + margin: 0 ($baseline*2) ($baseline/4) 0; + font-weight: 300; + } + + // course metadata + .course-metadata { + @extend %t-copy-sub1; + @include transition(opacity $tmg-f1 ease-in-out 0); + color: $gray; + opacity: 0.75; + + .metadata-item { + display: inline-block; + + &:after { + content: "/"; + margin-left: ($baseline/10); + margin-right: ($baseline/10); + color: $gray-l4; + } + + &:last-child { + + &:after { + content: ""; + margin-left: 0; + margin-right: 0; + } + } + + .label { + @extend %cont-text-sr; + } + } + } + + .course-actions { + @include transition(opacity $tmg-f2 ease-in-out 0); + @extend %ui-depth3; + position: static; + width: flex-grid(3, 9); + text-align: right; + opacity: 0; + pointer-events: none; + + .action { + display: inline-block; + vertical-align: middle; + margin-right: ($baseline/2); + + &:last-child { + margin-right: 0; + } + } + + .button { + @extend %t-action3; + } + + // view live button + .view-button { + @include box-sizing(border-box); + padding: ($baseline/2); + } + + // course re-run button + .action-rerun { + margin-right: $baseline; + } + + .rerun-button { + font-weight: 600; + // TODO: sync up button styling and add secondary style here + } + } + + // CASE: is processing + &.is-processing { + + .course-status .value { + color: $gray-l2; + } + } + + // CASE: has an error + &.has-error { + + .course-status { + color: $red; // TODO: abstract this out to an error-based color variable + } + + ~ .status-message { + background: $red-l1; // TODO: abstract this out to an error-based color variable + color: $white; + } + } + + // CASE: last course in listing + &:last-child { + border-bottom: none; + } + } + + // ==================== + + // CASE: courses that are being processed + .courses-processing { + margin-bottom: ($baseline*2); + border-bottom: 1px solid $gray-l3; + padding-bottom: ($baseline*2); + + // TODO: abstract this case out better with normal course listings + .list-courses { + border: none; + background: none; + box-shadow: none; + } + + .wrapper-course { + @extend %ui-window; + position: relative; + } + + .course-item { + border: none; + + // STATE: hover/focus + &:hover { + background: inherit; + + .course-title { + color: inherit; + } + } + + } + + // course details (replacement for course-link when a course cannot be linked) + .course-details { + @extend %ui-depth2; + display: inline-block; + vertical-align: middle; + width: flex-grid(6, 9); + margin-right: flex-gutter(); + } + + } + + // ==================== + // ELEM: new user form .wrapper-create-course { @@ -494,6 +681,5 @@ margin-bottom: 0; padding: ($baseline*.5) ($baseline*1.5) 8px ($baseline*1.5); } - } } diff --git a/cms/static/sass/views/_outline.scss b/cms/static/sass/views/_outline.scss index fa2d382181..e57569ac45 100644 --- a/cms/static/sass/views/_outline.scss +++ b/cms/static/sass/views/_outline.scss @@ -15,6 +15,10 @@ vertical-align: top; } + .incontext-editor-action-wrapper { + position: relative; + } + .incontext-editor-open-action { @include transition(opacity $tmg-f1 ease-in-out 0); opacity: 0.0; @@ -171,14 +175,316 @@ @extend %expand-collapse; } + // course status + // -------------------- + .course-status { + margin-bottom: $baseline; + + .status-release { + @extend %t-copy-base; + display: inline-block; + color: $color-copy-base; + } + + .status-release-label, + .status-release-value, + .status-actions { + display: inline-block; + vertical-align: middle; + margin-bottom: 0; + } + + .status-release-label { + margin-right: ($baseline/4); + } + + .status-release-value { + @extend %t-strong; + } + + .status-actions { + @extend %actions-list; + @include transition(opacity $tmg-f1 ease-in-out 0); + margin-left: ($baseline/4); + opacity: 0.0; + } + + // STATE: hover + &:hover { + + .status-actions { + opacity: 1.0; + } + } + } + // outline // -------------------- - .outline { + + // UI: simple version of the outline + .outline-simple { + + } + + // UI: complex version of the outline + .outline-complex { .outline-content { margin-top: 0; } + // outline: items + .outline-item { + + // CASE: expand/collapse-able + &.is-collapsible { + + // only select the current item's toggle expansion controls + &:nth-child(1) .ui-toggle-expansion, &:nth-child(1) .item-title { + + // STATE: hover/active + &:hover, &:active { + color: $blue; + } + } + + &.is-dragging { + @include transition-property(none); + } + } + + // item: title + .item-title { + + // STATE: is-editable + &.is-editable { + + // editor + + .editor { + display: block; + + .item-edit-title { + width: 100%; + } + } + } + } + + // STATE: drag and drop + .drop-target-prepend .draggable-drop-indicator-initial { + opacity: 1.0; + } + + // STATE: was dropped + &.was-dropped { + border-color: $blue; + } + } + + // outline: sections + // -------------------- + .outline-section { + padding: ($baseline*0.75) $baseline ($baseline*0.75) ($baseline + 4); + + // header + .section-header { + @extend %outline-item-header; + + .incontext-editor-input { + @extend %t-strong; + @extend %t-title5; + } + } + + .section-header-details { + float: left; + width: flex-grid(6, 9); + + .icon, .wrapper-section-title { + display: inline-block; + vertical-align: top; + } + + .icon { + margin-right: ($baseline/4); + } + + .wrapper-section-title { + width: flex-grid(5, 6); + line-height: 0; + } + } + + .section-header-actions { + float: right; + width: flex-grid(3, 9); + margin-top: -($baseline/4); + text-align: right; + + .actions-list { + @extend %actions-list; + @extend %t-action2; + } + } + + // in-context actions + .incontext-editor-action-wrapper { + top: -($baseline/20); + } + + // status + .section-status { + margin: 0 0 0 ($outline-indent-width*1.25); + } + + // content + .section-content { + @extend %outline-item-content-shown; + } + + // CASE: is-collapsible + &.is-collapsible { + @extend %ui-expand-collapse; + + .ui-toggle-expansion { + @extend %t-icon3; + color: $gray-l3; + } + } + + // STATE: is-collapsed + &.is-collapsed { + + .section-content { + @extend %outline-item-content-hidden; + } + } + + // STATE: drag and drop - was dropped + &.was-dropped { + border-left-color: $ui-action-primary-color-focus; + } + } + + // outline: subsections + // -------------------- + .list-subsections { + margin: $baseline 0 0 0; + } + + .outline-subsection { + padding: ($baseline*0.75); + + // header + .subsection-header { + @extend %outline-item-header; + + .incontext-editor-input { + @extend %t-title6; + } + } + + .subsection-header-details { + float: left; + width: flex-grid(6, 9); + + .icon, .wrapper-subsection-title { + display: inline-block; + vertical-align: top; + } + + .icon { + margin-right: ($baseline/4); + } + + .wrapper-subsection-title { + width: flex-grid(5, 6); + margin-top: -($baseline/10); + line-height: 0; + } + } + + .subsection-header-actions { + float: right; + width: flex-grid(3, 9); + margin-top: -($baseline/4); + text-align: right; + + .actions-list { + @extend %actions-list; + @extend %t-action2; + margin-right: ($baseline/2); + } + } + + // in-context actions + .incontext-editor-action-wrapper { + top: -($baseline/10); + } + + // status + .subsection-status { + margin: 0 0 0 $outline-indent-width; + } + + // content + .subsection-content { + @extend %outline-item-content-shown; + } + + // CASE: is-collapsible + &.is-collapsible { + @extend %ui-expand-collapse; + + .ui-toggle-expansion { + @extend %t-icon4; + color: $gray-l3; + } + } + + // STATE: is-collapsed + &.is-collapsed { + + .subsection-content { + @extend %outline-item-content-hidden; + } + } + } + + // outline: units + // -------------------- + .list-units { + margin: $baseline 0 0 0; + } + + .outline-unit { + @include transition(margin $tmg-f2 linear 0s); // needed for drag and drop transitions + margin-left: $outline-indent-width; + + // header + .unit-header { + @extend %outline-item-header; + } + + .unit-header-details { + float: left; + width: flex-grid(6, 9); + margin-top: ($baseline/4); + } + + .unit-header-actions { + float: right; + width: flex-grid(3, 9); + margin-top: -($baseline/10); + text-align: right; + + .actions-list { + @extend %actions-list; + @extend %t-action2; + } + } + } + // add/new items .add-item { margin-top: ($baseline*0.75); @@ -209,248 +515,6 @@ } } - // outline: items - .outline-item { - - // CASE: expand/collapse-able - &.is-collapsible { - - // only select the current item's toggle expansion controls - &:nth-child(1) .ui-toggle-expansion, &:nth-child(1) .item-title { - - // STATE: hover/active - &:hover, &:active { - color: $blue; - } - } - - &.is-dragging { - @include transition-property(none); - } - } - - // item: title - .item-title { - - // STATE: is-editable - &.is-editable { - - // editor - + .editor { - display: block; - - .item-edit-title { - width: 100%; - } - } - } - } - - // STATE: drag and drop - .drop-target-prepend .draggable-drop-indicator-initial { - opacity: 1.0; - } - - // STATE: was dropped - &.was-dropped { - border-color: $blue; - } - } - - // outline: sections - // -------------------- - .outline-section { - padding: ($baseline*0.75) $baseline ($baseline*0.75) ($baseline + 4); - - // header - .section-header { - @extend %outline-item-header; - - .incontext-editor-input { - @extend %t-strong; - @extend %t-title5; - } - } - - .section-header-details { - float: left; - width: flex-grid(6, 9); - - .icon, .wrapper-section-title { - display: inline-block; - vertical-align: top; - } - - .icon { - margin-right: ($baseline/4); - } - - .wrapper-section-title { - width: flex-grid(5, 6); - line-height: 0; - } - } - - .section-header-actions { - float: right; - width: flex-grid(3, 9); - margin-top: -($baseline/4); - text-align: right; - - .actions-list { - @extend %actions-list; - @extend %t-action2; - } - } - - // status - .section-status { - margin: 0 0 0 ($outline-indent-width*1.25); - } - - // content - .section-content { - @extend %outline-item-content-shown; - } - - // CASE: is-collapsible - &.is-collapsible { - @extend %ui-expand-collapse; - - .ui-toggle-expansion { - @extend %t-icon3; - color: $gray-l3; - } - } - - // STATE: is-collapsed - &.is-collapsed { - - .section-content { - @extend %outline-item-content-hidden; - } - } - - // STATE: drag and drop - was dropped - &.was-dropped { - border-left-color: $ui-action-primary-color-focus; - } - } - - // outline: subsections - // -------------------- - .list-subsections { - margin: $baseline 0 0 0; - } - - .outline-subsection { - padding: ($baseline*0.75); - - // header - .subsection-header { - @extend %outline-item-header; - - .incontext-editor-input { - @extend %t-title6; - } - } - - .subsection-header-details { - float: left; - width: flex-grid(6, 9); - - .icon, .wrapper-subsection-title { - display: inline-block; - vertical-align: top; - } - - .icon { - margin-right: ($baseline/4); - } - - .wrapper-subsection-title { - width: flex-grid(5, 6); - margin-top: -($baseline/10); - line-height: 0; - } - } - - .subsection-header-actions { - float: right; - width: flex-grid(3, 9); - margin-top: -($baseline/4); - text-align: right; - - .actions-list { - @extend %actions-list; - @extend %t-action2; - margin-right: ($baseline/2); - } - } - - // status - .subsection-status { - margin: 0 0 0 $outline-indent-width; - } - - // content - .subsection-content { - @extend %outline-item-content-shown; - } - - // CASE: is-collapsible - &.is-collapsible { - @extend %ui-expand-collapse; - - .ui-toggle-expansion { - @extend %t-icon4; - color: $gray-l3; - } - } - - // STATE: is-collapsed - &.is-collapsed { - - .subsection-content { - @extend %outline-item-content-hidden; - } - } - } - - // outline: units - // -------------------- - .list-units { - margin: $baseline 0 0 0; - } - - .outline-unit { - @include transition(margin $tmg-f2 linear 0s); // needed for drag and drop transitions - margin-left: $outline-indent-width; - - // header - .unit-header { - @extend %outline-item-header; - } - - .unit-header-details { - float: left; - width: flex-grid(6, 9); - margin-top: ($baseline/4); - } - - .unit-header-actions { - float: right; - width: flex-grid(3, 9); - margin-top: -($baseline/10); - text-align: right; - - .actions-list { - @extend %actions-list; - @extend %t-action2; - } - } - } - // UI: drag and drop: section // -------------------- @@ -530,4 +594,147 @@ bottom: -($baseline/2); } } + + // outline: edit item settings + .wrapper-modal-window-bulkpublish-section, + .wrapper-modal-window-bulkpublish-subsection, + .wrapper-modal-window-bulkpublish-unit, + .course-outline-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; + } + } + } + + // outline: bulk publishing items + .bulkpublish-section-modal, + .bulkpublish-subsection-modal, + .bulkpublish-unit-modal { + + .modal-introduction { + color: $gray-d2; + } + + .modal-section .outline-bulkpublish { + max-height: ($baseline*20); + overflow-y: auto; + } + + .outline-section, + .outline-subsection { + border: none; + padding: 0; + } + + .outline-subsection { + margin-bottom: $baseline; + padding-right: ($baseline/4); + } + + .outline-subsection .subsection-title { + @extend %t-title8; + margin-bottom: ($baseline/4); + font-weight: 600; + color: $gray-l2; + text-transform: uppercase; + } + + .outline-unit .unit-title, .outline-unit .unit-status { + display: inline-block; + vertical-align: middle; + } + + .outline-unit .unit-title { + @extend %t-title7; + color: $color-heading-base; + } + + .outline-unit .unit-status { + @extend %t-copy-sub2; + text-align: right; + } + + } + + // it is the only element there + .bulkpublish-unit-modal { + .modal-introduction { + margin-bottom: 0; + } + } + } diff --git a/cms/templates/base.html b/cms/templates/base.html index 72b8cfcc0e..a0ca8fc724 100644 --- a/cms/templates/base.html +++ b/cms/templates/base.html @@ -343,7 +343,9 @@ <% online_help_token = self.online_help_token() if hasattr(self, 'online_help_token') else None %> <%include file="widgets/header.html" args="online_help_token=online_help_token" /> -
+
+ <%block name="page_alert"> +
<%block name="content"> diff --git a/cms/templates/container.html b/cms/templates/container.html index 4610acfa48..3cb25af477 100644 --- a/cms/templates/container.html +++ b/cms/templates/container.html @@ -144,7 +144,7 @@ templates = ["basic-modal", "modal-button", "edit-xblock-modal",
${_("Location in Course Outline")}
-
+
diff --git a/cms/templates/course-create-rerun.html b/cms/templates/course-create-rerun.html new file mode 100644 index 0000000000..04bd8044b7 --- /dev/null +++ b/cms/templates/course-create-rerun.html @@ -0,0 +1,158 @@ +<%inherit file="base.html" /> +<%def name="online_help_token()"><% return "course_rerun" %> +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> + +<%block name="title">${_("Create a Course Rerun of:")} +<%block name="bodyclass">is-signedin view-course-create view-course-create-rerun + +<%block name="jsextra"> + + + + + +<%block name="content"> +
+
+
+

+ ${_("Create a re-run of a course")} +

+ + + +

+ ${_("You are creating a re-run from:")} + ${source_course_key.org} ${source_course_key.course} ${source_course_key.run} + ${display_name} +

+
+
+ +
+
+
+
+
+
+

+ ${_("Provide identifying information for this re-run of the course. The original course is not affected in any way by a re-run.")} + ${_("Note: Together, the organization, course number, and course run must uniquely identify this new course instance.")} +

+

+
+ +
+
+ + + +
+
+ ${_("Required Information to Create a re-run of a course")} + +
    +
  1. + + + + ${_("The public display name for the new course. (This name is often the same as the original course name.)")} + + +
  2. +
  3. + + + + ${_("The name of the organization sponsoring the new course. (This name is often the same as the original organization name.)")} + ${_("Note: No spaces or special characters are allowed.")} + + +
  4. + +
  5. +
    + + + + ${_("The unique number that identifies the new course within the organization. (This number is often the same as the original course number.)")} + ${_("Note: No spaces or special characters are allowed.")} + + +
    + +
    + + + + ${_("The term in which the new course will run. (This value is often different than the original course run value.)")} + ${_("Note: No spaces or special characters are allowed.")} + + +
    +
  6. +
+ + +
+
+ +
+ + +
+
+
+ +
+ + + +
+
+ +
+
+ diff --git a/cms/templates/course_outline.html b/cms/templates/course_outline.html index 8940d18bcf..8ddf22e58d 100644 --- a/cms/templates/course_outline.html +++ b/cms/templates/course_outline.html @@ -29,13 +29,38 @@ from contentstore.utils import reverse_usage_url <%block name="header_extras"> -% for template_name in ['course-outline', 'xblock-string-field-editor', 'basic-modal', 'modal-button', 'edit-outline-item-modal']: +% for template_name in ['course-outline', 'xblock-string-field-editor', 'basic-modal', 'modal-button', 'course-outline-modal', 'due-date-editor', 'release-date-editor', 'grading-editor', 'publish-editor', 'staff-lock-editor']: % endfor +<%block name="page_alert"> + %if notification_dismiss_url is not None: +
+
+ + +
+

${_("This course was created as a re-run. Some manual configuration is needed.")}

+ +

${_("No course content is currently visible, and no students are enrolled. Be sure to review and reset all dates, including the Course Start Date; set up the course team; review course updates and other assets for dated material; and seed the discussions and wiki.")}

+
+ + +
+
+ %endif + + <%block name="content">
@@ -70,11 +95,28 @@ from contentstore.utils import reverse_usage_url
+
+
+

${_("Course Start Date:")}

+

${course_release_date}

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

${_("Course Outline")}

+
@@ -83,13 +125,20 @@ from contentstore.utils import reverse_usage_url
-
diff --git a/cms/templates/index.html b/cms/templates/index.html index 0b37c9ecfc..eed1669c58 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -77,7 +77,7 @@ require(["domReady!", "jquery", "jquery.form", "js/index"], function(doc, $) { % if course_creator_status=='granted':
-
+
- +
@@ -131,35 +131,139 @@ require(["domReady!", "jquery", "jquery.form", "js/index"], function(doc, $) {
% endif + + %if allow_course_reruns and rerun_creator_status and len(in_process_course_actions) > 0: +
+

Courses Being Processed

+ +
    + %for course_info in sorted(in_process_course_actions, key=lambda s: s['display_name'].lower() if s['display_name'] is not None else ''): + + %if course_info['is_in_progress']: +
  • +
    +
    +

    ${course_info['display_name']}

    + + +
    + +
    +
    This re-run processing status:
    +
    + + Configuring as re-run +
    +
    +
    + +
    +

    ${_('The new course will be added to your course list in 5-10 minutes. Return to this page or refresh it to update the course list. The new course will need some manual configuration.')}

    +
    +
  • + %endif + + + + + %if course_info['is_failed']: +
  • +
    +
    +

    ${course_info['display_name']}

    + + +
    + +
    +
    This re-run processing status:
    +
    + + Configuration Error +
    +
    +
    + +
    +

    ${_("A system error occurred while your course was being processed. Please go to the original course to try the re-run again, or contact your PM for assistance.")}

    + + +
    +
  • + %endif + %endfor +
+
+ %endif + %if len(courses) > 0:
diff --git a/cms/templates/js/course-outline-modal.underscore b/cms/templates/js/course-outline-modal.underscore new file mode 100644 index 0000000000..51a2d79f13 --- /dev/null +++ b/cms/templates/js/course-outline-modal.underscore @@ -0,0 +1,7 @@ +
+ + +
+ diff --git a/cms/templates/js/course-outline.underscore b/cms/templates/js/course-outline.underscore index 32a2ef6061..d38f1ff40a 100644 --- a/cms/templates/js/course-outline.underscore +++ b/cms/templates/js/course-outline.underscore @@ -1,16 +1,15 @@ <% -var category = xblockInfo.get('category'); var releasedToStudents = xblockInfo.get('released_to_students'); var visibilityState = xblockInfo.get('visibility_state'); var published = xblockInfo.get('published'); var statusMessage = null; var statusType = null; -if (visibilityState === 'staff_only') { +if (staffOnlyMessage) { statusType = 'staff-only'; statusMessage = gettext('Contains staff only content'); } else if (visibilityState === 'needs_attention') { - if (category === 'vertical') { + if (xblockInfo.isVertical()) { statusType = 'warning'; if (published && releasedToStudents) { statusMessage = gettext('Unpublished changes to live content'); @@ -63,6 +62,14 @@ if (xblockInfo.get('graded')) {
- <% } else if (category !== 'vertical') { %> + <% } else if (!xblockInfo.isVertical()) { %>
  1. diff --git a/cms/templates/js/due-date-editor.underscore b/cms/templates/js/due-date-editor.underscore new file mode 100644 index 0000000000..a1200f8ca0 --- /dev/null +++ b/cms/templates/js/due-date-editor.underscore @@ -0,0 +1,22 @@ +
      +
    • + + +
    • + +
    • + + +
    • +
    + + diff --git a/cms/templates/js/edit-outline-item-modal.underscore b/cms/templates/js/edit-outline-item-modal.underscore deleted file mode 100644 index 6d3c90cf34..0000000000 --- a/cms/templates/js/edit-outline-item-modal.underscore +++ /dev/null @@ -1,89 +0,0 @@ -
    - - - - - - <% if (xblockInfo.isSequential()) { %> - - <% } %> - -
    diff --git a/cms/templates/js/grading-editor.underscore b/cms/templates/js/grading-editor.underscore new file mode 100644 index 0000000000..7db05aa5ff --- /dev/null +++ b/cms/templates/js/grading-editor.underscore @@ -0,0 +1,14 @@ + + diff --git a/cms/templates/js/mock/mock-bad-javascript-container-xblock.underscore b/cms/templates/js/mock/mock-bad-javascript-container-xblock.underscore new file mode 100644 index 0000000000..032655ab31 --- /dev/null +++ b/cms/templates/js/mock/mock-bad-javascript-container-xblock.underscore @@ -0,0 +1,54 @@ +
    +
    +
    + Test Container +
    +
    +
      +
    +
    +
    +
    +
    +
    +
      +
    1. +
      +
      +
      +
      +
        +
      • + +
      • +
      • + +
      • +
      • + +
      • +
      • + +
      • +
      +
      +
      +
      + +
      +
      + +
      +
      +
      +
    2. +
    +
    +
    diff --git a/cms/templates/js/mock/mock-bad-xblock-container-xblock.underscore b/cms/templates/js/mock/mock-bad-xblock-container-xblock.underscore new file mode 100644 index 0000000000..77b0ee062d --- /dev/null +++ b/cms/templates/js/mock/mock-bad-xblock-container-xblock.underscore @@ -0,0 +1,51 @@ +
    +
    +
    + Test Container +
    +
    +
      +
    +
    +
    +
    +
    +
    +
      +
    1. +
      +
      +
      +
      +
        +
      • + +
      • +
      • + +
      • +
      • + +
      • +
      • + +
      • +
      +
      +
      +
      + +
      +
      +
      +
      +
      +
    2. +
    +
    +
    diff --git a/cms/templates/js/mock/mock-container-xblock.underscore b/cms/templates/js/mock/mock-container-xblock.underscore index 79f032759a..468e82c6ac 100644 --- a/cms/templates/js/mock/mock-container-xblock.underscore +++ b/cms/templates/js/mock/mock-container-xblock.underscore @@ -10,11 +10,11 @@
-
  1. -
    +
    @@ -35,11 +35,10 @@
    -
    +
    1. -
      +
      @@ -64,9 +63,7 @@
    2. -
      - +
      @@ -91,8 +88,7 @@
    3. -
      +
      @@ -123,7 +119,7 @@
    4. -
      +
      @@ -144,12 +140,10 @@
      -
      +
      1. -
        - +
        @@ -174,9 +168,7 @@
      2. -
        - +
        @@ -201,9 +193,7 @@
      3. -
        - +
        diff --git a/cms/templates/js/mock/mock-course-rerun-notification.underscore b/cms/templates/js/mock/mock-course-rerun-notification.underscore new file mode 100644 index 0000000000..12c5bcab68 --- /dev/null +++ b/cms/templates/js/mock/mock-course-rerun-notification.underscore @@ -0,0 +1,24 @@ +
        +
        +
        + + +
        +

        This course was created as a re-run. Some manual configuration is needed.

        + +

        No course content is currently visible, and no students are enrolled. Be sure to review and reset all + dates, including the Course Start Date; set up the course team; review course updates and other + assets for dated material; and seed the discussions and wiki.

        +
        + + +
        +
        +
        \ No newline at end of file diff --git a/cms/templates/js/mock/mock-create-course-rerun.underscore b/cms/templates/js/mock/mock-create-course-rerun.underscore new file mode 100644 index 0000000000..43b9ff0b26 --- /dev/null +++ b/cms/templates/js/mock/mock-create-course-rerun.underscore @@ -0,0 +1,116 @@ +
        +
        +
        +

        + Create a re-run of a course +

        + + + +

        + You are creating a re-run from: + edX Open_DemoX 2014_T1 + edX Demonstration Course +

        +
        +
        + +
        +
        +
        +
        +
        +
        +

        + Provide identifying information for this re-run of the course. The original course is not affected in any way by a re-run. + Note: Together, the organization, course number, and course run must uniquely identify this new course instance. +

        +

        +
        + +
        +
        + + +
        +
        + Required Information to Create a re-run of a course + +
          +
        1. + + + + The public display name for the new course. (This name is often the same as the original course name.) + + +
        2. +
        3. + + + + The name of the organization sponsoring the new course. (This name is often the same as the original organization name.) + Note: No spaces or special characters are allowed. + + +
        4. + +
        5. +
          + + + + The unique number that identifies the new course within the organization. (This number is often the same as the original course number.) + Note: No spaces or special characters are allowed. + + +
          + +
          + + + + The term in which the new course will run. (This value is often different than the original course run value.) + Note: No spaces or special characters are allowed. + + +
          +
        6. +
        + + +
        +
        + +
        + + +
        +
        +
        +
        +
        +
        +
        +
        \ No newline at end of file diff --git a/cms/templates/js/mock/mock-index-page.underscore b/cms/templates/js/mock/mock-index-page.underscore new file mode 100644 index 0000000000..f63f12e571 --- /dev/null +++ b/cms/templates/js/mock/mock-index-page.underscore @@ -0,0 +1,168 @@ +
        +
        +

        My Courses

        + +
        +
        + +
        +
        +
        + +
        +

        Welcome, user!

        +
        +

        Here are all of the courses you currently have access to in Studio:

        +
        +
        + +
        +
        +
        + +
        + +
        +

        Create a New Course

        + +
        + Required Information to Create a New Course + +
          +
        1. + + + The public display name for your course. This cannot be changed, but you can set a different display name in Advanced Settings later. + +
        2. +
        3. + + + The name of the organization sponsoring the course. Note: This is part of your course URL, so no spaces or special characters are allowed. This cannot be changed, but you can set a different display name in Advanced Settings later. + +
        4. + +
        5. + + + The unique number that identifies your course within your organization. Note: This is part of your course URL, so no spaces or special characters are allowed and it cannot be changed. + +
        6. + +
        7. + + + The term in which your course will run. Note: This is part of your course URL, so no spaces or special characters are allowed and it cannot be changed. + +
        8. +
        + +
        +
        + +
        + + + +
        +
        +
        + + +
        +

        Courses Being Processed

        + +
          + +
        • +
          +
          +

          Demo Course

          + + +
          + +
          +
          This re-run processing status:
          +
          + + Configuring as re-run +
          +
          +
          + +
          +

          The new course will be added to your course list in 5-10 minutes. Return to this page or refresh it to update the course list. The new course will need some manual configuration.

          +
          +
        • + + + + +
        • +
          +
          +

          Demo Course 2

          + + +
          + +
          +
          This re-run processing status:
          +
          + + Configuration Error +
          +
          +
          + +
          +

          A system error occurred while your course was being processed. Please go to the original course to try the re-run again, or contact your PM for assistance.

          + + +
          +
        • +
        +
        +
        +
        +
        diff --git a/cms/templates/js/mock/mock-xmodule-editor-with-custom-tabs.underscore b/cms/templates/js/mock/mock-xmodule-editor-with-custom-tabs.underscore index a9f5fe1b34..b37459e21f 100644 --- a/cms/templates/js/mock/mock-xmodule-editor-with-custom-tabs.underscore +++ b/cms/templates/js/mock/mock-xmodule-editor-with-custom-tabs.underscore @@ -1,11 +1,17 @@ -
        +
        diff --git a/cms/templates/js/mock/mock-xmodule-editor.underscore b/cms/templates/js/mock/mock-xmodule-editor.underscore index 8c179ef704..38bc6f197e 100644 --- a/cms/templates/js/mock/mock-xmodule-editor.underscore +++ b/cms/templates/js/mock/mock-xmodule-editor.underscore @@ -1,4 +1,6 @@ -
        +
        @@ -24,7 +26,8 @@ -
        diff --git a/cms/templates/js/publish-editor.underscore b/cms/templates/js/publish-editor.underscore new file mode 100644 index 0000000000..27c4ce2902 --- /dev/null +++ b/cms/templates/js/publish-editor.underscore @@ -0,0 +1,38 @@ +<% if (!xblockInfo.isVertical()) { %> + +<% } %> diff --git a/cms/templates/js/publish-xblock.underscore b/cms/templates/js/publish-xblock.underscore index 4133c9f1b0..b830f095c7 100644 --- a/cms/templates/js/publish-xblock.underscore +++ b/cms/templates/js/publish-xblock.underscore @@ -46,10 +46,11 @@ var visibleToStaffOnly = visibilityState === 'staff_only';
        <%= releaseLabel %>

        <% if (releaseDate) { %> - <% var message = gettext("%(release_date)s with %(section_or_subsection)s") %> - <%= interpolate(message, { - release_date: '' + releaseDate + '', - section_or_subsection: '' + releaseDateFrom + '' }, true) %> + <%= releaseDate %> + + <%= interpolate(gettext('with %(release_date_from)s'), { release_date_from: releaseDateFrom }, true) %> + + <% } else { %> <%= gettext("Unscheduled") %> <% } %> @@ -65,13 +66,20 @@ var visibleToStaffOnly = visibilityState === 'staff_only'; <% } %> <% if (visibleToStaffOnly) { %> -

        <%= gettext("Staff Only") %>

        +

        + <%= gettext("Staff Only") %> + <% if (!hasExplicitStaffLock) { %> + + <%= interpolate(gettext("with %(section_or_subsection)s"),{ section_or_subsection: staffLockFrom }, true) %> + + <% } %> +

        <% } else { %>

        <%= gettext("Staff and Students") %>

        <% } %>

        - - <% if (visibleToStaffOnly) { %> + + <% if (hasExplicitStaffLock) { %> <% } else { %> diff --git a/cms/templates/js/release-date-editor.underscore b/cms/templates/js/release-date-editor.underscore new file mode 100644 index 0000000000..75e70c7d25 --- /dev/null +++ b/cms/templates/js/release-date-editor.underscore @@ -0,0 +1,28 @@ +

        + diff --git a/cms/templates/js/staff-lock-editor.underscore b/cms/templates/js/staff-lock-editor.underscore new file mode 100644 index 0000000000..67074c0647 --- /dev/null +++ b/cms/templates/js/staff-lock-editor.underscore @@ -0,0 +1,35 @@ +
        + + +
        \ No newline at end of file diff --git a/cms/templates/login.html b/cms/templates/login.html index 7a3266bdff..82988ebd75 100644 --- a/cms/templates/login.html +++ b/cms/templates/login.html @@ -24,8 +24,8 @@ from django.utils.translation import ugettext as _
        1. - - + +
        2. diff --git a/cms/templates/manage_users.html b/cms/templates/manage_users.html index 53ba31cb46..22f82192ab 100644 --- a/cms/templates/manage_users.html +++ b/cms/templates/manage_users.html @@ -34,7 +34,7 @@
          %if allow_actions:
          -
          +

          ${_("Add a User to Your Course's Team")}

          @@ -44,7 +44,7 @@
          1. - + ${_("Please provide the email address of the course staff member you'd like to add")}
          @@ -147,7 +147,7 @@

          ${_("Course Team Roles")}

          ${_("Course team members, or staff, are course co-authors. They have full writing and editing privileges on all course content.")}

          -

          ${_("Admins are course team members who can add and remove other course team members.")}

          +

          ${_("Admins are course team members who can add and remove other course team members.")}

          % if user_is_instuctor and len(instructors) == 1: diff --git a/cms/templates/register.html b/cms/templates/register.html index 3ef794aeee..9986f5ba57 100644 --- a/cms/templates/register.html +++ b/cms/templates/register.html @@ -27,18 +27,18 @@
          1. - - + +
          2. - +
          3. - + ${_("This will be used in public discussions with your courses and in our edX101 support forums")}
          4. diff --git a/cms/templates/settings.html b/cms/templates/settings.html index 0be08715ef..9065103cf4 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -7,6 +7,7 @@ <%! from django.utils.translation import ugettext as _ from contentstore import utils + import urllib %> <%block name="header_extras"> @@ -93,13 +94,26 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s

            ${_("Course Summary Page")} ${_("(for student enrollment and access)")}

            -

            https:${lms_link_for_about_page}

            + <% + link_for_about_page = ("https:" if is_secure else "http:") + lms_link_for_about_page + %> +

            ${link_for_about_page}

            @@ -316,7 +330,7 @@ require(["domReady!", "jquery", "js/models/settings/course_details", "js/views/s - % if settings.FEATURES.get('ENABLE_GROUP_CONFIGURATIONS') and "split_test" in context_course.advanced_modules: + % if "split_test" in context_course.advanced_modules: % endif diff --git a/cms/templates/settings_advanced.html b/cms/templates/settings_advanced.html index 386c72adfa..4cbdf4cfed 100644 --- a/cms/templates/settings_advanced.html +++ b/cms/templates/settings_advanced.html @@ -126,7 +126,7 @@ require(["domReady!", "jquery", "gettext", "js/models/settings/advanced", "js/vi - % if settings.FEATURES.get('ENABLE_GROUP_CONFIGURATIONS') and "split_test" in context_course.advanced_modules: + % if "split_test" in context_course.advanced_modules: % endif diff --git a/cms/templates/settings_graders.html b/cms/templates/settings_graders.html index 506bedbe69..c8687b1a8c 100644 --- a/cms/templates/settings_graders.html +++ b/cms/templates/settings_graders.html @@ -148,7 +148,7 @@ require(["domReady!", "jquery", "js/views/settings/grading", "js/models/settings - % if settings.FEATURES.get('ENABLE_GROUP_CONFIGURATIONS') and "split_test" in context_course.advanced_modules: + % if "split_test" in context_course.advanced_modules: % endif diff --git a/cms/templates/textbooks.html b/cms/templates/textbooks.html index 2a65244c72..7334d28754 100644 --- a/cms/templates/textbooks.html +++ b/cms/templates/textbooks.html @@ -75,7 +75,10 @@ require(["js/models/section", "js/collections/textbook", "js/views/list_textbook

            ${_("What if my book isn't divided into chapters?")}

            ${_("If your textbook doesn't have individual chapters, you can upload the entire text as a single chapter and enter a name of your choice in the Chapter Name field.")}

            -

            ${_("Learn More")}

            +
            + +
        diff --git a/cms/templates/ux/reference/course-create-rerun.html b/cms/templates/ux/reference/course-create-rerun.html new file mode 100644 index 0000000000..b05ee661fe --- /dev/null +++ b/cms/templates/ux/reference/course-create-rerun.html @@ -0,0 +1,368 @@ + + +<%inherit file="../../base.html" /> + +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> + +<%block name="title">[template] ${_("Create a Course Rerun of HarvardX SW12.2x T2_2014")} +<%block name="bodyclass">is-signedin view-course-create view-course-create-rerun + +<%block name="content"> +
        + +
        +
        +

        + ${_("Create a re-run of a course")} +

        + + + +

        + ${_("You are creating a re-run from:")} + HarvardX SW12.2x T2_2014 + China (Part 2): The Creation and End of a Centralized Empire +

        +
        +
        + +
        +
        +
        +
        +
        +
        +

        + ${_("Provide identifying information for this re-run of the course. The original course is not affected in any way by a re-run.")} + ${_("Note: Together, the organization, course number, and course run must uniquely identify this new course instance.")} +

        +

        +
        + + + + +
        + + +
        + +
        + +
        +
        + ${_("Required Information to Create a re-run of a course")} + +
          +
        1. + + + + ${_("The public display name for the new course. (This name is often the same as the original course name.)")} + + +
        2. +
        3. + + + + ${_("The name of the organization sponsoring the new course. (This name is often the same as the original organization name.)")} + ${_("Note: No spaces or special characters are allowed.")} + + +
        4. + +
        5. +
          + + + + ${_("The unique number that identifies the new course within the organization. (This number is often the same as the original course number.)")} + ${_("Note: No spaces or special characters are allowed.")} + + +
          + +
          + + + + ${_("The term in which the new course will run. (This value is often different than the original course run value.)")} + ${_("Note: No spaces or special characters are allowed.")} + + +
          +
        6. +
        + + +
        +
        + +
        + + +
        + +
        + + + + +
        +
        +
        + + +
        + +
        +
        + ${_("Required Information to Create a re-run of a course")} + +
          +
        1. + + + + ${_("The public display name for the new course. (This name is often the same as the original course name.)")} + + +
        2. +
        3. + + + + ${_("The name of the organization sponsoring the new course. (This name is often the same as the original organization name.)")} + ${_("Note: No spaces or special characters are allowed.")} + + +
        4. + +
        5. +
          + + + + ${_("The unique number that identifies the new course within the organization. (This number is often the same as the original course number.)")} + ${_("Note: No spaces or special characters are allowed.")} + + +
          + +
          + + + + ${_("The term in which the new course will run. (This value is often different than the original course run value.)")} + ${_("Note: No spaces or special characters are allowed.")} + + +
          +
        6. +
        + + + +
        +
        + +
        + + +
        +
        +
        + + + + +
        +
        +
        + + +
        + +
        +
        + ${_("Required Information to Create a re-run of a course")} + +
          +
        1. + + + + ${_("The public display name for the new course. (This name is often the same as the original course name.)")} + + Required field. +
        2. +
        3. + + + + ${_("The name of the organization sponsoring the new course. (This name is often the same as the original organization name.)")} + ${_("Note: No spaces or special characters are allowed.")} + + Please do not use any spaces or special characters in this field. +
        4. + +
        5. +
          + + + + ${_("The unique number that identifies the new course within the organization. (This number is often the same as the original course number.)")} + ${_("Note: No spaces or special characters are allowed.")} + + Please do not use any spaces or special characters in this field. +
          + +
          + + + + ${_("The term in which the new course will run. (This value is often different than the original course run value.)")} + ${_("Note: No spaces or special characters are allowed.")} + + Required field. +
          +
        6. +
        + + + +
        +
        + +
        + + +
        +
        +
        + + + + +
        +
        +
        +
        + +
        +
        + ${_("Required Information to Create a re-run of a course")} + +
          +
        1. + + + + ${_("The public display name for the new course. (This name is often the same as the original course name.)")} + + +
        2. +
        3. + + + + ${_("The name of the organization sponsoring the new course. (This name is often the same as the original organization name.)")} + ${_("Note: No spaces or special characters are allowed.")} + + +
        4. + +
        5. +
          + + + + ${_("The unique number that identifies the new course within the organization. (This number is often the same as the original course number.)")} + ${_("Note: No spaces or special characters are allowed.")} + + +
          + +
          + + + + ${_("The term in which the new course will run. (This value is often different than the original course run value.)")} + ${_("Note: No spaces or special characters are allowed.")} + + +
          +
        6. +
        + + +
        +
        + +
        + +
        +
        +
        + +
        + + + +
        +
        + +
        +
        + diff --git a/cms/templates/ux/reference/modal_bulkpublish-section.html b/cms/templates/ux/reference/modal_bulkpublish-section.html new file mode 100644 index 0000000000..ee5e548a72 --- /dev/null +++ b/cms/templates/ux/reference/modal_bulkpublish-section.html @@ -0,0 +1,143 @@ +<%! from django.utils.translation import ugettext as _ %> + + diff --git a/cms/templates/ux/reference/modal_bulkpublish-subsection.html b/cms/templates/ux/reference/modal_bulkpublish-subsection.html new file mode 100644 index 0000000000..980cbc9933 --- /dev/null +++ b/cms/templates/ux/reference/modal_bulkpublish-subsection.html @@ -0,0 +1,73 @@ +<%! from django.utils.translation import ugettext as _ %> + + diff --git a/cms/templates/ux/reference/modal_bulkpublish-unit.html b/cms/templates/ux/reference/modal_bulkpublish-unit.html new file mode 100644 index 0000000000..8106aa5255 --- /dev/null +++ b/cms/templates/ux/reference/modal_bulkpublish-unit.html @@ -0,0 +1,30 @@ +<%! from django.utils.translation import ugettext as _ %> + + diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index 1b6a6fff0d..3f42d06ace 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -84,7 +84,7 @@ - % if settings.FEATURES.get('ENABLE_GROUP_CONFIGURATIONS') and "split_test" in context_course.advanced_modules: + % if "split_test" in context_course.advanced_modules: diff --git a/cms/templates/widgets/metadata-edit.html b/cms/templates/widgets/metadata-edit.html index 3af94c4633..3404bcaf21 100644 --- a/cms/templates/widgets/metadata-edit.html +++ b/cms/templates/widgets/metadata-edit.html @@ -5,6 +5,7 @@ import hashlib import copy import json + from xmodule.modulestore import EdxJSONEncoder hlskey = hashlib.md5(module.location.to_deprecated_string().encode('utf-8')).hexdigest() %> @@ -33,4 +34,4 @@ <%include file="source-edit.html" /> % endif -
        -
        {{abbreviatedBody}}
        +
        {{{abbreviatedBody}}}
      ${_("View discussion")} diff --git a/lms/templates/instructor/instructor_dashboard_2/course_info.html b/lms/templates/instructor/instructor_dashboard_2/course_info.html index c27119fb60..3e00ebf5bb 100644 --- a/lms/templates/instructor/instructor_dashboard_2/course_info.html +++ b/lms/templates/instructor/instructor_dashboard_2/course_info.html @@ -76,10 +76,13 @@

      ${_("Pending Instructor Tasks")}

      -

      ${_("The status for any active tasks appears in a table below.")}

      -
      +
      +

      ${_("The status for any active tasks appears in a table below.")}

      +
      -
      +
      +
      +
      %endif diff --git a/lms/templates/instructor/instructor_dashboard_2/data_download.html b/lms/templates/instructor/instructor_dashboard_2/data_download.html index a3ce752df6..b931e93855 100644 --- a/lms/templates/instructor/instructor_dashboard_2/data_download.html +++ b/lms/templates/instructor/instructor_dashboard_2/data_download.html @@ -69,8 +69,11 @@

      ${_("Pending Instructor Tasks")}

      -

      ${_("The status for any active tasks appears in a table below.")}

      -
      -
      +
      +

      ${_("The status for any active tasks appears in a table below.")}

      +
      +
      +
      +
      %endif diff --git a/lms/templates/instructor/instructor_dashboard_2/extensions.html b/lms/templates/instructor/instructor_dashboard_2/extensions.html index 3b11f5eea9..2aad064e00 100644 --- a/lms/templates/instructor/instructor_dashboard_2/extensions.html +++ b/lms/templates/instructor/instructor_dashboard_2/extensions.html @@ -12,7 +12,7 @@

      ${_("Specify the {platform_name} email address or username of a student " "here:").format(platform_name=settings.PLATFORM_NAME)} - +

      ${_("Choose the graded unit:")} @@ -39,7 +39,7 @@


      -
      +

      ${_("Viewing granted extensions")}

      ${_("Here you can see what extensions have been granted on particular " @@ -67,7 +67,7 @@

      ${_("Specify the {platform_name} email address or username of a student " "here:").format(platform_name=settings.PLATFORM_NAME)} - + @@ -90,7 +90,7 @@

      ${_("Specify the {platform_name} email address or username of a student " "here:").format(platform_name=settings.PLATFORM_NAME)} - +

      ${_("Choose the graded unit:")} diff --git a/lms/templates/instructor/instructor_dashboard_2/send_email.html b/lms/templates/instructor/instructor_dashboard_2/send_email.html index b44c87bc6c..54471cfd15 100644 --- a/lms/templates/instructor/instructor_dashboard_2/send_email.html +++ b/lms/templates/instructor/instructor_dashboard_2/send_email.html @@ -59,10 +59,13 @@


      ${_("Pending Instructor Tasks")}

      -

      ${_("Email actions run in the background. The status for any active tasks - including email tasks - appears in a table below.")}

      -
      +
      +

      ${_("Email actions run in the background. The status for any active tasks - including email tasks - appears in a table below.")}

      +
      -
      +
      +
      +
      diff --git a/lms/templates/instructor/instructor_dashboard_2/student_admin.html b/lms/templates/instructor/instructor_dashboard_2/student_admin.html index 6e4bd3a879..c10c1c2384 100644 --- a/lms/templates/instructor/instructor_dashboard_2/student_admin.html +++ b/lms/templates/instructor/instructor_dashboard_2/student_admin.html @@ -117,10 +117,13 @@

      ${_("Pending Instructor Tasks")}

      -

      ${_("The status for any active tasks appears in a table below.")}

      -
      +
      +

      ${_("The status for any active tasks appears in a table below.")}

      +
      -
      +
      +
      +
      %endif diff --git a/lms/templates/main.html b/lms/templates/main.html index fefc4deec7..e426937d37 100644 --- a/lms/templates/main.html +++ b/lms/templates/main.html @@ -20,6 +20,9 @@ <%def name="theme_enabled()"> <% return settings.FEATURES.get("USE_CUSTOM_THEME", False) %> +<%def name="is_microsite()"> + <% return microsite.is_request_in_microsite() %> + <%def name="stanford_theme_enabled()"> <% @@ -61,7 +64,7 @@ <%block name="headextra"/> <% - if theme_enabled(): + if theme_enabled() and not is_microsite(): header_extra_file = 'theme-head-extra.html' header_file = 'theme-header.html' google_analytics_file = 'theme-google-analytics.html' @@ -71,7 +74,13 @@ else: header_extra_file = None - header_file = microsite.get_template_path('navigation.html') + + if settings.FEATURES.get("ENABLE_NEW_EDX_HEADER", False): + header_file = microsite.get_template_path('navigation.html') + else: + header_file = microsite.get_template_path('original_navigation.html') + + google_analytics_file = microsite.get_template_path('google_analytics.html') if getattr(settings, 'SITE_NAME', '').endswith('edx.org'): diff --git a/lms/templates/navigation.html b/lms/templates/navigation.html index 4852757aea..03075917f5 100644 --- a/lms/templates/navigation.html +++ b/lms/templates/navigation.html @@ -36,7 +36,7 @@ site_status_msg = get_site_status_msg(course_id) % endif -
      +
    5. % endif - % if not settings.FEATURES['DISABLE_LOGIN_BUTTON']: - % if course and settings.FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD') and course.enrollment_domain: - - % else: - - % endif - % endif
    +{% endblock %} diff --git a/lms/templates/registration/password_reset_confirm.html b/lms/templates/registration/password_reset_confirm.html index ad048d458a..6bf9f3e194 100644 --- a/lms/templates/registration/password_reset_confirm.html +++ b/lms/templates/registration/password_reset_confirm.html @@ -1,29 +1,15 @@ +{% extends "main_django.html" %} {% load i18n %} -{% load compressed %} -{% load staticfiles %} - - - +{% block title %} {% blocktrans with platform_name=platform_name %} Reset Your {{ platform_name }} Password {% endblocktrans %} +{% endblock %} - {% compressed_css 'style-vendor' %} - {% compressed_css 'style-app' %} - {% compressed_css 'style-app-extend1' %} - {% compressed_css 'style-app-extend2' %} - - {% block main_vendor_js %} - {% compressed_js 'main_vendor' %} - {% endblock %} - - - +{% block bodyextra %} +{% endblock %} - +{% block bodyclass %}view-passwordreset{% endblock %} - - -
    - -
    - -
    -
    -
    -
    -

    +{% block body %} +
    +
    +

    + {% blocktrans with platform_name=platform_name %} Reset Your {{ platform_name }} Password {% endblocktrans %} -

    -
    -
    + +

    +
    +
    +
    {% if validlink %}

    {% trans "Password Reset Form" %}

    -
    {% csrf_token %} + {% csrf_token %}
    -
    +{% endblock %} diff --git a/lms/templates/split_test_author_view.html b/lms/templates/split_test_author_view.html index 49477874a5..ece87663c8 100644 --- a/lms/templates/split_test_author_view.html +++ b/lms/templates/split_test_author_view.html @@ -5,7 +5,7 @@ split_test = context.get('split_test') user_partition = split_test.descriptor.get_selected_partition() messages = split_test.descriptor.validation_messages() -show_link = settings.FEATURES.get('ENABLE_GROUP_CONFIGURATIONS') and group_configuration_url is not None +show_link = group_configuration_url is not None %> % if is_root and not is_configured: diff --git a/lms/templates/split_test_staff_view.html b/lms/templates/split_test_staff_view.html index e66a3d7452..28bbd66534 100644 --- a/lms/templates/split_test_staff_view.html +++ b/lms/templates/split_test_staff_view.html @@ -4,7 +4,7 @@ diff --git a/lms/templates/verify_student/_verification_header.html b/lms/templates/verify_student/_verification_header.html index 682652b870..39a33e76eb 100644 --- a/lms/templates/verify_student/_verification_header.html +++ b/lms/templates/verify_student/_verification_header.html @@ -1,5 +1,10 @@ <%! from django.utils.translation import ugettext as _ %> +## TODO (ECOM-16): This is part of an AB-test of auto-registration. +## Once the test completes, we can make the winning configuration the default +## and remove this flag. +%if not autoreg: + + +%else: + + +%endif diff --git a/lms/templates/verify_student/_verification_support.html b/lms/templates/verify_student/_verification_support.html index de54e63346..389852bad8 100644 --- a/lms/templates/verify_student/_verification_support.html +++ b/lms/templates/verify_student/_verification_support.html @@ -1,5 +1,10 @@ <%! from django.utils.translation import ugettext as _ %> +## TODO (ECOM-16): This is part of an AB-test of auto-registration. +## Once the test completes, we can make the winning configuration the default +## and remove this flag. +%if not autoreg: +
    + +%else: + +
    + +
    +%endif diff --git a/lms/templates/widgets/segment-io.html b/lms/templates/widgets/segment-io.html index e3fec19206..5909ad64e3 100644 --- a/lms/templates/widgets/segment-io.html +++ b/lms/templates/widgets/segment-io.html @@ -32,23 +32,27 @@ if (url.indexOf("/register") > -1) { // Registration page viewed analytics.track("edx.bi.page.register.viewed", { - category: "pageview" + category: "pageview", + noninteraction: 1 }); } else if (url.indexOf("/login") > -1) { // Login page viewed analytics.track("edx.bi.page.login.viewed", { - category: "pageview" + category: "pageview", + noninteraction: 1 }); } else if (url.indexOf("/dashboard") > -1) { // Dashboard viewed analytics.track("edx.bi.page.dashboard.viewed", { - category: "pageview" + category: "pageview", + noninteraction: 1 }); } else { // This event serves as a catch-all, firing when any other page is viewed analytics.track("edx.bi.page.other.viewed", { category: "pageview", - url: location.host + location.pathname + location.search + url: location.host + location.pathname + location.search, + noninteraction: 1 }); } diff --git a/lms/urls.py b/lms/urls.py index 05df6193b4..f363bd2a74 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -180,7 +180,7 @@ if settings.WIKI_ENABLED: # never be returned by a reverse() so they come after the other url patterns url(r'^courses/{}/course_wiki/?$'.format(settings.COURSE_ID_PATTERN), 'course_wiki.views.course_wiki_redirect', name="course_wiki"), - url(r'^courses/(?:[^/]+/[^/]+/[^/]+)/wiki/', include(wiki_pattern())), + url(r'^courses/{}/wiki/'.format(settings.COURSE_ID_PATTERN), include(wiki_pattern())), ) if settings.COURSEWARE_ENABLED: @@ -224,6 +224,17 @@ if settings.COURSEWARE_ENABLED: 'student.views.change_enrollment', name="change_enrollment"), url(r'^change_email_settings$', 'student.views.change_email_settings', name="change_email_settings"), + # Used for an AB-test of auto-registration + # TODO (ECOM-16): Based on the AB-test, update the default behavior and change + # this URL to point to the original view. Eventually, this URL + # should be removed, but not the AB test completes. + url( + r'^change_enrollment_autoreg$', + 'student.views.change_enrollment', + {'auto_register': True}, + name="change_enrollment_autoreg", + ), + #About the course url(r'^courses/{}/about$'.format(settings.COURSE_ID_PATTERN), 'courseware.views.course_about', name="about_course"), diff --git a/mongo_indexes.md b/mongo_indexes.md index 0764f82afe..df8ce78ce5 100644 --- a/mongo_indexes.md +++ b/mongo_indexes.md @@ -20,16 +20,14 @@ which can be `uploadDate`, `display_name`, Replace existing index which leaves out `run` with this one: ``` -ensureIndex({'_id.tag': 1, '_id.org': 1, '_id.course': 1, '_id.category': 1, '_id.run': 1}) -ensureIndex({'content_son.tag': 1, 'content_son.org': 1, 'content_son.course': 1, 'content_son.category': 1, 'content_son.run': 1}) +ensureIndex({'_id.org': 1, '_id.course': 1, '_id.name': 1}, {'sparse': true}) +ensureIndex({'content_son.org': 1, 'content_son.course': 1, 'content_son.name': 1}, {'sparse': true}) +ensureIndex({'_id.org': 1, '_id.course': 1, 'uploadDate': 1}, {'sparse': true}) +ensureIndex({'_id.org': 1, '_id.course': 1, 'display_name': 1}, {'sparse': true}) +ensureIndex({'content_son.org': 1, 'content_son.course': 1, 'uploadDate': 1}, {'sparse': true}) +ensureIndex({'content_son.org': 1, 'content_son.course': 1, 'display_name': 1}, {'sparse': true}) ``` -Note: I'm not advocating adding one which leaves out `category` for now because that would only be -used for `delete_all_course_assets` which in the future should not actually delete the assets except -when doing garbage collection. - -Remove index on `displayname` - modulestore: ============ @@ -41,7 +39,7 @@ and no field is omitted. Because we often query for some subset of the id, we define this index: ``` -ensureIndex({'_id.tag': 1, '_id.org': 1, '_id.course': 1, '_id.category': 1, '_id.name': 1, '_id.revision': 1}) +ensureIndex({'_id.org': 1, '_id.course': 1, '_id.category': 1, '_id.name': 1}) ``` Because we often scan for all category='course' regardless of the value of the other fields: @@ -51,54 +49,12 @@ ensureIndex({'_id.category': 1}) Because lms calls get_parent_locations frequently (for path generation): ``` -ensureIndex({'_id.tag': 1, '_id.org': 1, '_id.course': 1, 'definition.children': 1}) -``` - -Remove these indices if they exist as I can find no use for them: -``` - { "_id.course": 1, "_id.org": 1, "_id.revision": 1, "definition.children": 1 } - { "definition.children": 1 } -``` - -NOTE, that index will only aid queries which provide the keys in exactly that form and order. The query can -omit later fields of the query but not earlier. Thus ```modulestore.find({'_id.org': 'myu'})``` will not use -the index as it omits the tag. As soon as mongo comes across an index field omitted from the query, it stops -considering the index. On the other hand, ```modulestore.find({'_id.tag': 'i4x', '_id.org': 'myu', '_id.category': 'problem'})``` -will use the index to get the records matching the tag and org and then will scan all of them -for matches to the category. - -To find out if any records have the wrong id structure, run -``` -db.fs.files.find({uploadDate: {$gt: startDate, $lt: endDate}, - $where: function() { - var keys = ['category', 'name', 'course', 'tag', 'org', 'revision']; - for (var key in this._id) { - if (key != keys.shift()) { - return true; - } - } - return false; - }}, - {_id: 1}) +ensureIndex({'definition.children': 1}, {'sparse': true}) ``` modulestore.active_versions =========================== ``` -ensureIndex({'org': 1, 'offering': 1}) +ensureIndex({'org': 1, 'course': 1, 'run': 1}, {'unique': true}) ``` - -modulestore.structures -====================== - -``` -ensureIndex({'previous_version': 1}) -``` - -modulestore.definitions -======================= - -``` -ensureIndex({'category': 1}) -``` \ No newline at end of file diff --git a/pavelib/prereqs.py b/pavelib/prereqs.py index c0efdff3a8..096c242409 100644 --- a/pavelib/prereqs.py +++ b/pavelib/prereqs.py @@ -102,9 +102,11 @@ def ruby_prereqs_installation(): def node_prereqs_installation(): """ - Installs Node prerequisites + Configures npm and installs Node prerequisites """ - sh("npm config set registry {}".format(NPM_REGISTRY)) + sh("test `npm config get registry` = \"{reg}\" || " + "(echo setting registry; npm config set registry" + " {reg})".format(reg=NPM_REGISTRY)) sh('npm install') diff --git a/pavelib/utils/test/suites/bokchoy_suite.py b/pavelib/utils/test/suites/bokchoy_suite.py index ded8d904ca..da98d021e6 100644 --- a/pavelib/utils/test/suites/bokchoy_suite.py +++ b/pavelib/utils/test/suites/bokchoy_suite.py @@ -103,6 +103,7 @@ class BokChoyTestSuite(TestSuite): cmd = [ "SCREENSHOT_DIR='{}'".format(self.log_dir), "HAR_DIR='{}'".format(self.har_dir), + "SELENIUM_DRIVER_LOG_DIR='{}'".format(self.log_dir), "nosetests", test_spec, "--with-xunit", diff --git a/pavelib/utils/test/utils.py b/pavelib/utils/test/utils.py index ccc9f66563..d42488557b 100644 --- a/pavelib/utils/test/utils.py +++ b/pavelib/utils/test/utils.py @@ -3,6 +3,11 @@ Helper functions for test tasks """ from paver.easy import sh, task from pavelib.utils.envs import Env +import os + +MONGO_PORT_NUM = int(os.environ.get('EDXAPP_TEST_MONGO_PORT', '27017')) +MONGO_HOST = os.environ.get('EDXAPP_TEST_MONGO_HOST', 'localhost') + __test__ = False # do not collect @@ -43,4 +48,8 @@ def clean_mongo(): """ Clean mongo test databases """ - sh("mongo {repo_root}/scripts/delete-mongo-test-dbs.js".format(repo_root=Env.REPO_ROOT)) + sh("mongo {host}:{port} {repo_root}/scripts/delete-mongo-test-dbs.js".format( + host=MONGO_HOST, + port=MONGO_PORT_NUM, + repo_root=Env.REPO_ROOT, + )) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index f5e65f53be..471041a97b 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -10,6 +10,7 @@ bleach==1.4 html5lib==0.999 boto==2.13.3 celery==3.0.19 +cssselect==0.9.1 dealer==0.2.3 distribute>=0.6.28, <0.7 django-babel-underscore==0.1.0 diff --git a/requirements/edx/edx-private.txt b/requirements/edx/edx-private.txt index 086198b451..4a10aed7ad 100644 --- a/requirements/edx/edx-private.txt +++ b/requirements/edx/edx-private.txt @@ -1,7 +1,7 @@ # Requirements for edx.org that aren't necessarily needed for Open edX. -e git+ssh://git@github.com/jazkarta/edX-jsdraw.git@9fcd333aaa2ac3df65dd247b601ce0b56bb10cad#egg=edx-jsdraw --e git+https://github.com/gsehub/xblock-mentoring.git@e292496295dbb6e6d692015171a7dbaf45385321#egg=xblock-mentoring +-e git+https://github.com/gsehub/xblock-mentoring.git@d4532e4f89aaf36b56715be2abc0f9402912794e#egg=xblock-mentoring # Prototype XBlocks from edX learning sciences limited roll-outs and user testing. # Concept XBlock, in particular, is nowhere near finished and an early prototype. diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 9c25a0addf..319f50452a 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -18,17 +18,17 @@ -e git+https://github.com/jazkarta/edx-jsme.git@813079fd5218ed275248d2a1fcae2fcbf20a0838#egg=edx-jsme # Our libraries: --e git+https://github.com/edx/XBlock.git@f0e53538be7ce90584a03cc7dd3f06bd43e12ac2#egg=XBlock +-e git+https://github.com/edx/XBlock.git@8943ed7730bd8af5ab929ad74537b387793b29cc#egg=XBlock -e git+https://github.com/edx/codejail.git@71f5c5616e2a73ae8cecd1ff2362774a773d3665#egg=codejail -e git+https://github.com/edx/diff-cover.git@v0.5.0#egg=diff_cover -e git+https://github.com/edx/js-test-tool.git@v0.1.5#egg=js_test_tool -e git+https://github.com/edx/django-waffle.git@823a102e48#egg=django-waffle -e git+https://github.com/edx/event-tracking.git@0.1.0#egg=event-tracking --e git+https://github.com/edx/edx-analytics-api-client.git@0.1.0#egg=analytics-client --e git+https://github.com/edx/bok-choy.git@9162c0bfb8e0eb1e2fa8e6df8dec12d181322a90#egg=bok_choy +-e git+https://github.com/edx/edx-analytics-data-api-client.git@0.1.0#egg=edx-analytics-data-api-client +-e git+https://github.com/edx/bok-choy.git@15756f029016e033c658380f77218fe8467948b5#egg=bok_choy -e git+https://github.com/edx-solutions/django-splash.git@7579d052afcf474ece1239153cffe1c89935bc4f#egg=django-splash -e git+https://github.com/edx/acid-block.git@459aff7b63db8f2c5decd1755706c1a64fb4ebb1#egg=acid-xblock -e git+https://github.com/edx/edx-ora2.git@release-2014-08-08T13.47#egg=edx-ora2 -e git+https://github.com/edx/opaque-keys.git@454bd984d9539550c6290020e92ee2d6094038d0#egg=opaque-keys -e git+https://github.com/edx/ease.git@97de68448e5495385ba043d3091f570a699d5b5f#egg=ease --e git+https://github.com/edx/i18n-tools.git@f5303e82dff368c7595884d9325aeea1d802da25#egg=i18n-tools +-e git+https://github.com/edx/i18n-tools.git@0d7847f9dfa2281640527b4dc51f5854f950f9b7#egg=i18n-tools