From 96cde1e2e5fce5133fbf1b8b2cad0724369ecc8b Mon Sep 17 00:00:00 2001 From: salmannawaz Date: Tue, 12 May 2026 22:45:26 +0500 Subject: [PATCH] chore: fix generic container block issue for add components --- .../v1/views/tests/test_vertical_block.py | 90 +++++++++++++++++++ cms/djangoapps/contentstore/utils.py | 44 +++++++++ cms/static/js/views/pages/container.js | 7 +- 3 files changed, 138 insertions(+), 3 deletions(-) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py index 260ed4732320..ae8bda35c7de 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py @@ -2,11 +2,14 @@ Unit tests for the vertical block. """ +from contextlib import ExitStack +from unittest.mock import patch from urllib.parse import quote from django.urls import reverse from edx_toggles.toggles.testutils import override_waffle_flag from rest_framework import status +from xblock.utils.studio_editable import NestedXBlockSpec from xblock.validation import ValidationMessage from cms.djangoapps.contentstore.tests.utils import CourseTestCase @@ -200,6 +203,93 @@ def test_not_valid_usage_key_string(self): response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) # noqa: PT009 + def _patch_vertical_as_nested_container(self, specs): + """ + Return a single context manager that makes the vertical appear to be a + StudioContainerWithNestedXBlocksMixin instance for the duration of the test, + returning the given NestedXBlockSpec list from get_nested_blocks_spec(). + + Replaces StudioContainerWithNestedXBlocksMixin in the utils module with + type(self.vertical) so that isinstance(xblock, ) is True for + any VerticalBlock instance, then patches get_nested_blocks_spec to return + the caller-supplied specs. + """ + stack = ExitStack() + stack.enter_context(patch( + 'cms.djangoapps.contentstore.utils.StudioContainerWithNestedXBlocksMixin', + type(self.vertical), + )) + stack.enter_context(patch.object( + type(self.vertical), 'get_nested_blocks_spec', return_value=specs, create=True, + )) + return stack + + def test_component_templates_filtered_for_nested_xblocks_mixin(self): + """ + Check that component_templates only contains templates whose category is + listed in get_nested_blocks_spec() when the container implements + StudioContainerWithNestedXBlocksMixin. + """ + url = self.get_reverse_url(self.vertical.location) + specs = [NestedXBlockSpec(None, category='html'), NestedXBlockSpec(None, category='video')] + + with self._patch_vertical_as_nested_container(specs): + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + component_templates = response.json().get('component_templates', []) + self.assertGreater(len(component_templates), 0) # noqa: PT009 + for group in component_templates: + for template in group.get('templates', []): + self.assertIn(template['category'], {'html', 'video'}) # noqa: PT009 + + def test_component_templates_surfaces_spec_fields(self): + """ + Check that single_instance, disabled, and disabled_reason from NestedXBlockSpec + are surfaced onto the matching template dict so the MFE can disable buttons + and show tooltips. + """ + url = self.get_reverse_url(self.vertical.location) + specs = [ + NestedXBlockSpec(None, category='html', single_instance=True), + NestedXBlockSpec(None, category='video', disabled=True, disabled_reason='Not available'), + ] + + with self._patch_vertical_as_nested_container(specs): + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + component_templates = response.json().get('component_templates', []) + all_templates = [ + template + for group in component_templates + for template in group.get('templates', []) + ] + + html_templates = [template for template in all_templates if template['category'] == 'html'] + self.assertGreater(len(html_templates), 0) # noqa: PT009 + for template in html_templates: + self.assertTrue(template.get('single_instance')) # noqa: PT009 + + video_templates = [template for template in all_templates if template['category'] == 'video'] + self.assertGreater(len(video_templates), 0) # noqa: PT009 + for template in video_templates: + self.assertTrue(template.get('disabled')) # noqa: PT009 + self.assertEqual(template.get('disabled_reason'), 'Not available') # noqa: PT009 + + def test_component_templates_unfiltered_for_non_mixin_xblock(self): + """ + Check that component_templates includes all default course-wide templates + without filtering when the container does not implement + StudioContainerWithNestedXBlocksMixin. + """ + url = self.get_reverse_url(self.vertical.location) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + component_templates = response.json().get('component_templates', []) + group_types = {group['type'] for group in component_templates} + self.assertGreater(len(group_types), 0) # noqa: PT009 + class ContainerVerticalViewTest(BaseXBlockContainer): """ diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 35818125efab..3b72a8c3a66e 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -34,6 +34,7 @@ from pytz import UTC from rest_framework.fields import BooleanField from xblock.fields import Scope +from xblock.utils.studio_editable import StudioContainerWithNestedXBlocksMixin from cms.djangoapps.contentstore.toggles import ( enable_course_optimizer, @@ -1974,6 +1975,46 @@ def _get_course_index_context(request, course_key, course_block): return course_index_context +def _filter_component_templates_for_xblock(component_templates, xblock): + """ + Filter and annotate component templates based on the XBlock's allowed nested blocks. + + If the XBlock implements StudioContainerWithNestedXBlocksMixin, only templates whose + category appears in ``get_nested_blocks_spec()`` are kept. Each surviving template dict + is annotated with any ``single_instance``, ``disabled``, or ``disabled_reason`` values + declared by the matching spec so the MFE can disable buttons and show tooltips. + + If the XBlock does not implement the mixin, the original list is returned unchanged. + """ + if not isinstance(xblock, StudioContainerWithNestedXBlocksMixin): + return component_templates + + specs_by_category = {spec.category: spec for spec in xblock.get_nested_blocks_spec()} + filtered_groups = [] + + for group in component_templates: + filtered_templates = [] + + for template in group["templates"]: + spec = specs_by_category.get(template["category"]) + if spec is None: + continue + + annotated = {**template} + if spec.single_instance: + annotated["single_instance"] = True + if spec.disabled: + annotated["disabled"] = True + if spec.disabled_reason: + annotated["disabled_reason"] = spec.disabled_reason + filtered_templates.append(annotated) + + if filtered_templates: + filtered_groups.append({**group, "templates": filtered_templates}) + + return filtered_groups + + def get_container_handler_context(request, usage_key, course, xblock): # pylint: disable=too-many-statements """ Utils is used to get context for container xblock requests. @@ -1995,6 +2036,9 @@ def get_container_handler_context(request, usage_key, course, xblock): # pylint course_sequence_ids = get_sequence_usage_keys(course) component_templates = get_component_templates(course) + + component_templates = _filter_component_templates_for_xblock(component_templates, xblock) + ancestor_xblocks = [] parent = get_parent_xblock(xblock) action = request.GET.get('action', 'view') diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index 543503a598df..aba80be001f4 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -313,9 +313,10 @@ function($, _, Backbone, gettext, BasePage, renderAddXBlockComponents: function() { var self = this; - // If the container is the Unit element(aka Vertical), then we don't render the - // add buttons because those should get rendered by the authoring MFE - if (self.options.canEdit && (!self.options.isIframeEmbed || !self.model.isVertical())) { + // When rendered inside the authoring MFE iframe, add buttons are always rendered + // natively by the MFE (for every container type, not just verticals), so the + // legacy add-button widgets must never be initialised here. + if (self.options.canEdit && !self.options.isIframeEmbed) { this.$('.add-xblock-component').each(function(index, element) { var component = new AddXBlockComponent({ el: element,