From f544a4825dffeef684401f4a7cf0f68b9c4b3549 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Thu, 29 Feb 2024 11:13:33 -0400 Subject: [PATCH] feat: make hide from TOC a visibility section setting (#33952) Exposes the hide_from_toc xblock attribute so course authors can configure it as a section visibility option in Studio. Before this change, the Hide from TOC functionality was mainly used by OLX components. Hence, it wasn't available for configuration through the Studio UI. Still, its implementation existed in the platform and could be used by setting the attribute: hide_from_toc=true as part of the OLX definition. Ref: https://openedx.atlassian.net/wiki/spaces/OEPM/pages/3853975595/Feature+Enhancement+Proposal+Hide+Sections+from+course+outline --- .../rest_api/v0/serializers/xblock.py | 1 + .../contentstore/tests/test_contentstore.py | 17 ++++++++ .../xblock_storage_handlers/view_handlers.py | 29 ++++++++++--- cms/static/js/models/xblock_info.js | 8 ++++ .../spec/views/pages/course_outline_spec.js | 26 +++++++++--- .../js/views/modals/course_outline_modals.js | 41 +++++++++++++++++-- .../js/views/pages/container_subviews.js | 1 + cms/static/js/views/utils/xblock_utils.js | 8 +++- cms/static/js/views/xblock_outline.js | 1 + cms/static/sass/elements/_modules.scss | 22 ++++++++++ .../partials/cms/theme/_variables-v1.scss | 1 + cms/static/sass/views/_container.scss | 9 ++++ cms/templates/js/course-outline.underscore | 17 +++++++- cms/templates/js/staff-lock-editor.underscore | 35 ++++++++++++++-- xmodule/modulestore/inheritance.py | 7 ++++ 15 files changed, 205 insertions(+), 18 deletions(-) diff --git a/cms/djangoapps/contentstore/rest_api/v0/serializers/xblock.py b/cms/djangoapps/contentstore/rest_api/v0/serializers/xblock.py index 4549326c9696..e95a76e91899 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/serializers/xblock.py +++ b/cms/djangoapps/contentstore/rest_api/v0/serializers/xblock.py @@ -77,3 +77,4 @@ class XblockSerializer(StrictSerializer): target_index = serializers.IntegerField(required=False, allow_null=True) boilerplate = serializers.JSONField(required=False, allow_null=True) staged_content = serializers.CharField(required=False, allow_null=True) + hide_from_toc = serializers.BooleanField(required=False, allow_null=True) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 9f90840156ca..e09ff48834db 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1445,6 +1445,23 @@ def test_create_block(self): ).replace('REPLACE', r'([0-9]|[a-f]){3,}') self.assertRegex(data['locator'], retarget) + @ddt.data(True, False) + def test_hide_xblock_from_toc_via_handler(self, hide_from_toc): + """Test that the hide_from_toc field can be set via the xblock_handler.""" + course = CourseFactory.create() + sequential = BlockFactory.create(parent_location=course.location) + data = { + "metadata": { + "hide_from_toc": hide_from_toc + } + } + + response = self.client.ajax_post(get_url("xblock_handler", sequential.location), data) + sequential = self.store.get_item(sequential.location) + + self.assertEqual(response.status_code, 200) + self.assertEqual(hide_from_toc, sequential.hide_from_toc) + def test_capa_block(self): """Test that a problem treats markdown specially.""" course = CourseFactory.create() diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index 44b89aa3a5f5..92f991b4704b 100644 --- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py @@ -1089,6 +1089,8 @@ def create_xblock_info( # lint-amnesty, pylint: disable=too-many-statements "group_access": xblock.group_access, "user_partitions": user_partitions, "show_correctness": xblock.show_correctness, + "hide_from_toc": xblock.hide_from_toc, + "enable_hide_from_toc_ui": settings.FEATURES.get("ENABLE_HIDE_FROM_TOC_UI", False), } ) @@ -1217,6 +1219,15 @@ def create_xblock_info( # lint-amnesty, pylint: disable=too-many-statements else: xblock_info["staff_only_message"] = False + if xblock_info["hide_from_toc"]: + xblock_info["hide_from_toc_message"] = True + elif child_info and child_info["children"]: + xblock_info["hide_from_toc_message"] = all( + child["hide_from_toc_message"] for child in child_info["children"] + ) + else: + xblock_info["hide_from_toc_message"] = False + # If the ENABLE_TAGGING_TAXONOMY_LIST_PAGE feature flag is enabled, we show the "Manage Tags" options if use_tagging_taxonomy_list_page(): xblock_info["use_tagging_taxonomy_list_page"] = True @@ -1345,6 +1356,7 @@ class VisibilityState: needs_attention = "needs_attention" staff_only = "staff_only" gated = "gated" + hide_from_toc = "hide_from_toc" def _compute_visibility_state( @@ -1355,6 +1367,8 @@ def _compute_visibility_state( """ if xblock.visible_to_staff_only: return VisibilityState.staff_only + elif xblock.hide_from_toc: + return VisibilityState.hide_from_toc elif is_unit_with_changes: # Note that a unit that has never been published will fall into this category, # as well as previously published units with draft content. @@ -1362,22 +1376,27 @@ def _compute_visibility_state( is_unscheduled = xblock.start == DEFAULT_START_DATE is_live = is_course_self_paced or datetime.now(UTC) > xblock.start - if child_info and child_info.get("children", []): + if child_info and child_info.get("children", []): # pylint: disable=too-many-nested-blocks all_staff_only = True all_unscheduled = True all_live = True + all_hide_from_toc = True for child in child_info["children"]: child_state = child["visibility_state"] if child_state == VisibilityState.needs_attention: return child_state elif not child_state == VisibilityState.staff_only: all_staff_only = False - if not child_state == VisibilityState.unscheduled: - all_unscheduled = False - if not child_state == VisibilityState.live: - all_live = False + if not child_state == VisibilityState.hide_from_toc: + all_hide_from_toc = False + if not child_state == VisibilityState.unscheduled: + all_unscheduled = False + if not child_state == VisibilityState.live: + all_live = False if all_staff_only: return VisibilityState.staff_only + elif all_hide_from_toc: + return VisibilityState.hide_from_toc elif all_unscheduled: return ( VisibilityState.unscheduled diff --git a/cms/static/js/models/xblock_info.js b/cms/static/js/models/xblock_info.js index 49812a6c9d78..2b82cf72b15b 100644 --- a/cms/static/js/models/xblock_info.js +++ b/cms/static/js/models/xblock_info.js @@ -177,6 +177,14 @@ define( * List of tags of the unit. This list is managed by the content_tagging module. */ tags: null, + /** + * True if the xblock is not visible to students only via links. + */ + hide_from_toc: null, + /** + * True iff this xblock should display a "Contains staff only content" message. + */ + hide_from_toc_message: null, }, initialize: function() { 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 a230d17797c7..7253938b31c2 100644 --- a/cms/static/js/spec/views/pages/course_outline_spec.js +++ b/cms/static/js/spec/views/pages/course_outline_spec.js @@ -42,6 +42,8 @@ describe('CourseOutlinePage', function() { user_partition_info: {}, highlights_enabled: true, highlights_enabled_for_messaging: false, + hide_from_toc: false, + enable_hide_from_toc_ui: true }, options, {child_info: {children: children}}); }; @@ -68,7 +70,9 @@ describe('CourseOutlinePage', function() { show_review_rules: true, user_partition_info: {}, highlights_enabled: true, - highlights_enabled_for_messaging: false + highlights_enabled_for_messaging: false, + hide_from_toc: false, + enable_hide_from_toc_ui: true }, options, {child_info: {children: children}}); }; @@ -93,7 +97,9 @@ describe('CourseOutlinePage', function() { group_access: {}, user_partition_info: {}, highlights: [], - highlights_enabled: true + highlights_enabled: true, + hide_from_toc: false, + enable_hide_from_toc_ui: true }, options, {child_info: {children: children}}); }; @@ -123,7 +129,9 @@ describe('CourseOutlinePage', function() { }, user_partitions: [], group_access: {}, - user_partition_info: {} + user_partition_info: {}, + hide_from_toc: false, + enable_hide_from_toc_ui: true }, options, {child_info: {children: children}}); }; @@ -141,7 +149,9 @@ describe('CourseOutlinePage', function() { edited_by: 'MockUser', user_partitions: [], group_access: {}, - user_partition_info: {} + user_partition_info: {}, + hide_from_toc: false, + enable_hide_from_toc_ui: true }, options); }; @@ -1214,7 +1224,9 @@ describe('CourseOutlinePage', function() { is_practice_exam: false, is_proctored_exam: false, default_time_limit_minutes: 150, - hide_after_due: true + hide_after_due: true, + hide_from_toc: false, + enable_hide_from_toc_ui: true, }, [ createMockVerticalJSON({ has_changes: true, @@ -1397,6 +1409,7 @@ describe('CourseOutlinePage', function() { default_time_limit_minutes: 150, hide_after_due: true, is_onboarding_exam: false, + hide_from_toc: null, } }); expect(requests[0].requestHeaders['X-HTTP-Method-Override']).toBe('PATCH'); @@ -2240,6 +2253,8 @@ describe('CourseOutlinePage', function() { is_practice_exam: false, is_proctored_exam: false, default_time_limit_minutes: null, + hide_from_toc: false, + enable_hide_from_toc_ui: true, }, [ createMockVerticalJSON({ has_changes: true, @@ -2521,6 +2536,7 @@ describe('CourseOutlinePage', function() { publish: 'republish', metadata: { visible_to_staff_only: null, + hide_from_toc: null } }); }) diff --git a/cms/static/js/views/modals/course_outline_modals.js b/cms/static/js/views/modals/course_outline_modals.js index f79e65d20779..f52340dea8fb 100644 --- a/cms/static/js/views/modals/course_outline_modals.js +++ b/cms/static/js/views/modals/course_outline_modals.js @@ -314,6 +314,8 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', var isProctoredExam = xblockInfo.get('is_proctored_exam'); var isPracticeExam = xblockInfo.get('is_practice_exam'); var isOnboardingExam = xblockInfo.get('is_onboarding_exam'); + var enableHideFromTOCUI = xblockInfo.get('enable_hide_from_toc_ui'); + var hideFromTOC = xblockInfo.get('hide_from_toc'); var html = this.template($.extend({}, { xblockInfo: xblockInfo, xblockType: this.options.xblockType, @@ -323,6 +325,8 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', isProctoredExam: isProctoredExam, isPracticeExam: isPracticeExam, isOnboardingExam: isOnboardingExam, + enableHideFromTOCUI: enableHideFromTOCUI, + hideFromTOC: hideFromTOC, isTimedExam: isTimeLimited && !( isProctoredExam || isPracticeExam || isOnboardingExam ), @@ -798,6 +802,10 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', return this.model.get('ancestor_has_staff_lock'); }, + isModelHiddenFromTOC: function() { + return this.model.get('hide_from_toc'); + }, + getContext: function() { return { hasExplicitStaffLock: this.isModelLocked(), @@ -812,6 +820,8 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', afterRender: function() { AbstractVisibilityEditor.prototype.afterRender.call(this); this.setLock(this.isModelLocked()); + this.setHideFromTOC(this.isModelHiddenFromTOC()); + this.setVisibleToLearners(this.isVisibleToLearners()); }, setLock: function(value) { @@ -822,8 +832,24 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', return this.$('#staff_lock').is(':checked'); }, + setHideFromTOC: function(value) { + this.$('#hide_from_toc').prop('checked', value); + }, + + setVisibleToLearners: function(value) { + this.$('#visible_to_learners').prop('checked', value); + }, + + isVisibleToLearners: function() { + return this.$('#staff_lock').is(':not(:checked)') && this.$('#hide_from_toc').is(':not(:checked)'); + }, + + isHiddenFromTOC: function() { + return this.$('#hide_from_toc').is(':checked'); + }, + hasChanges: function() { - return this.isModelLocked() !== this.isLocked(); + return this.isModelLocked() !== this.isLocked() || this.isModelHiddenFromTOC() !== this.isHiddenFromTOC(); }, getRequestData: function() { @@ -831,7 +857,8 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', return { publish: 'republish', metadata: { - visible_to_staff_only: this.isLocked() ? true : null + visible_to_staff_only: this.isLocked() || null, + hide_from_toc: this.isHiddenFromTOC() || null } }; } else { @@ -1055,12 +1082,20 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', if (this.currentVisibility() === 'staff_only') { metadata.visible_to_staff_only = true; metadata.hide_after_due = null; + metadata.hide_from_toc = null; } else if (this.currentVisibility() === 'hide_after_due') { metadata.visible_to_staff_only = null; metadata.hide_after_due = true; - } else { + metadata.hide_from_toc = null; + } else if (this.currentVisibility() === 'hide_from_toc'){ + metadata.visible_to_staff_only = null; + metadata.hide_after_due = null; + metadata.hide_from_toc = true; + } + else { metadata.visible_to_staff_only = null; metadata.hide_after_due = null; + metadata.hide_from_toc = null; } return { diff --git a/cms/static/js/views/pages/container_subviews.js b/cms/static/js/views/pages/container_subviews.js index 7ea09eff086e..2fe33df88eee 100644 --- a/cms/static/js/views/pages/container_subviews.js +++ b/cms/static/js/views/pages/container_subviews.js @@ -149,6 +149,7 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, MoveXBlockUtils, H releaseDate: this.model.get('release_date'), releaseDateFrom: this.model.get('release_date_from'), hasExplicitStaffLock: this.model.get('has_explicit_staff_lock'), + hideFromTOC: this.model.get('hide_from_toc'), staffLockFrom: this.model.get('staff_lock_from'), enableCopyUnit: this.model.get('enable_copy_paste_units'), course: window.course, diff --git a/cms/static/js/views/utils/xblock_utils.js b/cms/static/js/views/utils/xblock_utils.js index 9abe0866ed48..e955a0a3adc6 100644 --- a/cms/static/js/views/utils/xblock_utils.js +++ b/cms/static/js/views/utils/xblock_utils.js @@ -29,6 +29,8 @@ function($, _, gettext, ViewUtils, ModuleUtils, XBlockInfo, StringUtils) { * * staffOnly - all of the block's content is to be shown to staff only * Note: staff only items do not affect their parent's state. + * + * hideFromTOC - all of the block's content is to be hidden from the table of contents. */ VisibilityState = { live: 'live', @@ -36,7 +38,8 @@ function($, _, gettext, ViewUtils, ModuleUtils, XBlockInfo, StringUtils) { unscheduled: 'unscheduled', needsAttention: 'needs_attention', staffOnly: 'staff_only', - gated: 'gated' + gated: 'gated', + hideFromTOC: 'hide_from_toc' }; /** @@ -310,6 +313,9 @@ function($, _, gettext, ViewUtils, ModuleUtils, XBlockInfo, StringUtils) { if (visibilityState === VisibilityState.staffOnly) { return 'is-staff-only'; } + if (visibilityState === VisibilityState.hideFromTOC) { + return 'is-hidden-from-toc'; + } if (visibilityState === VisibilityState.gated) { return 'is-gated'; } diff --git a/cms/static/js/views/xblock_outline.js b/cms/static/js/views/xblock_outline.js index 4e826025b971..d5a12e6f0c91 100644 --- a/cms/static/js/views/xblock_outline.js +++ b/cms/static/js/views/xblock_outline.js @@ -112,6 +112,7 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, XBlockStringFieldE includesChildren: this.shouldRenderChildren(), hasExplicitStaffLock: this.model.get('has_explicit_staff_lock'), staffOnlyMessage: this.model.get('staff_only_message'), + hideFromTOCMessage: this.model.get('hide_from_toc_message'), course: course, enableCopyPasteUnits: this.model.get("enable_copy_paste_units"), // ENABLE_COPY_PASTE_UNITS waffle flag useTaggingTaxonomyListPage: this.model.get("use_tagging_taxonomy_list_page"), // ENABLE_TAGGING_TAXONOMY_LIST_PAGE waffle flag diff --git a/cms/static/sass/elements/_modules.scss b/cms/static/sass/elements/_modules.scss index 4f7532c915e3..29a2989c1ae9 100644 --- a/cms/static/sass/elements/_modules.scss +++ b/cms/static/sass/elements/_modules.scss @@ -507,6 +507,18 @@ $outline-indent-width: $baseline; } } + // CASE: is hidden from TOC + &.is-hidden-from-toc { + // needed to make sure direct children only + > .section-status, + > .subsection-status, + > .unit-status { + .status-message .icon { + color: $color-hide-from-toc; + } + } + } + // CASE: has gated content &.is-gated { @@ -603,6 +615,11 @@ $outline-indent-width: $baseline; border-left-color: $color-staff-only; } + // CASE: is hidden from TOC + &.is-hidden-from-toc { + border-left-color: $color-hide-from-toc; + } + // CASE: has gated content &.is-gated { border-left-color: $color-gated; @@ -698,6 +715,11 @@ $outline-indent-width: $baseline; border-left-color: $color-staff-only; } + // CASE: is hidden from TOC + &.is-hidden-from-toc { + border-left-color: $color-hide-from-toc; + } + // CASE: is presented for gated &.is-gated { border-left-color: $color-gated; diff --git a/cms/static/sass/partials/cms/theme/_variables-v1.scss b/cms/static/sass/partials/cms/theme/_variables-v1.scss index 96777b34f27a..c2ff073b94b1 100644 --- a/cms/static/sass/partials/cms/theme/_variables-v1.scss +++ b/cms/static/sass/partials/cms/theme/_variables-v1.scss @@ -218,6 +218,7 @@ $color-ready: $green !default; $color-warning: $orange-l2 !default; $color-error: $red-l2 !default; $color-staff-only: $black !default; +$color-hide-from-toc: $black !default; $color-gated: $black !default; $color-heading-base: $gray-d2 !default; diff --git a/cms/static/sass/views/_container.scss b/cms/static/sass/views/_container.scss index f28ac839c569..5a674ba6e03a 100644 --- a/cms/static/sass/views/_container.scss +++ b/cms/static/sass/views/_container.scss @@ -155,6 +155,15 @@ } } + // CASE: is hidden from TOC + &.is-hidden-from-toc{ + @extend %bar-module-black; + + &.is-scheduled .wrapper-release .copy { + text-decoration: line-through; + } + } + // CASE: content is gated &.is-gated { @extend %bar-module-black; diff --git a/cms/templates/js/course-outline.underscore b/cms/templates/js/course-outline.underscore index c62979bced1f..69b33f05ec29 100644 --- a/cms/templates/js/course-outline.underscore +++ b/cms/templates/js/course-outline.underscore @@ -22,7 +22,13 @@ var addStatusMessage = function (statusType, message) { statusIconClass = 'fa-warning'; } else if (statusType === 'staff-only' || statusType === 'gated') { statusIconClass = 'fa-lock'; - } else if (statusType === 'partition-groups') { + } else if(statusType === 'hide-from-toc-chapter') { + statusIconClass = 'fa-eye-slash'; + } + else if(statusType === 'hide-from-toc-sequence') { + statusIconClass = 'fa-ban'; + } + else if (statusType === 'partition-groups') { statusIconClass = 'fa-eye'; } @@ -49,6 +55,15 @@ if (staffOnlyMessage) { messageType = 'staff-only'; messageText = gettext('Contains staff only content'); addStatusMessage(messageType, messageText); +} else if (hideFromTOCMessage && !xblockInfo.isVertical()){ + if (xblockInfo.isChapter()) { + messageText = gettext('Hidden in the course outline, but sections are accessible for your learners through their separate link.'); + messageType = 'hide-from-toc-chapter'; + } else { + messageText = gettext('Subsections are not navigable between each other, they can only be accessed through their link.'); + messageType = 'hide-from-toc-sequence'; + } + addStatusMessage(messageType, messageText); } else { if (visibilityState === 'needs_attention' && xblockInfo.isVertical()) { messageType = 'warning'; diff --git a/cms/templates/js/staff-lock-editor.underscore b/cms/templates/js/staff-lock-editor.underscore index ff4417fbf391..f632001a8bff 100644 --- a/cms/templates/js/staff-lock-editor.underscore +++ b/cms/templates/js/staff-lock-editor.underscore @@ -6,11 +6,39 @@ <%- gettext('Section Visibility') %> <% } %> +
+<% if (enableHideFromTOCUI) { %> + + +<% } %>
diff --git a/xmodule/modulestore/inheritance.py b/xmodule/modulestore/inheritance.py index 489365e80e0d..4c5a14b769cb 100644 --- a/xmodule/modulestore/inheritance.py +++ b/xmodule/modulestore/inheritance.py @@ -240,6 +240,13 @@ class InheritanceMixin(XBlockMixin): scope=Scope.settings ) + hide_from_toc = Boolean( + display_name=_("Hide from Table of Contents"), + help=_("Enter true or false. If true, this block will be hidden from the Table of Contents."), + default=False, + scope=Scope.settings + ) + @property def close_date(self): """