Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, <that class>) 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):
"""
Expand Down
44 changes: 44 additions & 0 deletions cms/djangoapps/contentstore/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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')
Expand Down
7 changes: 4 additions & 3 deletions cms/static/js/views/pages/container.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading