From 557687075ad1fbd2932ddc1b041c5e2cd06ad023 Mon Sep 17 00:00:00 2001 From: salmannawaz Date: Tue, 12 May 2026 22:54:24 +0500 Subject: [PATCH 01/10] chore: render native add-component strip for generic container XBlocks --- src/course-unit/CourseUnit.tsx | 2 ++ .../add-component/AddComponent.tsx | 32 ++++++++++++++++--- .../add-component/add-component-btn/index.jsx | 28 +++++++++++++--- .../ComponentModalView.jsx | 12 +++++-- src/course-unit/hooks.tsx | 4 +++ 5 files changed, 68 insertions(+), 10 deletions(-) diff --git a/src/course-unit/CourseUnit.tsx b/src/course-unit/CourseUnit.tsx index 5e4c879dd0..75522ee767 100644 --- a/src/course-unit/CourseUnit.tsx +++ b/src/course-unit/CourseUnit.tsx @@ -191,6 +191,7 @@ const CourseUnit = () => { isUnitLegacyLibraryType, isSplitTestType, isProblemBankType, + isGenericContainerType, staticFileNotices, currentlyVisibleToStudents, unitXBlockActions, @@ -375,6 +376,7 @@ const CourseUnit = () => { isSplitTestType={isSplitTestType} isUnitVerticalType={isUnitVerticalType} isProblemBankType={isProblemBankType} + isGenericContainerType={isGenericContainerType} handleCreateNewCourseXBlock={handleCreateNewCourseXBlock} addComponentTemplateData={addComponentTemplateData} /> diff --git a/src/course-unit/add-component/AddComponent.tsx b/src/course-unit/add-component/AddComponent.tsx index 9dd8078f41..ae085ca14d 100644 --- a/src/course-unit/add-component/AddComponent.tsx +++ b/src/course-unit/add-component/AddComponent.tsx @@ -23,7 +23,7 @@ import { messageTypes } from '../constants'; import messages from './messages'; import AddComponentButton from './add-component-btn'; import ComponentModalView from './add-component-modals/ComponentModalView'; -import { getCourseSectionVertical, getCourseUnitData } from '../data/selectors'; +import { getCourseSectionVertical, getCourseUnitData, getCourseVerticalChildren } from '../data/selectors'; type ComponentTemplateData = { displayName: string; @@ -35,6 +35,9 @@ type ComponentTemplateData = { category?: string; displayName: string; supportLevel?: string | boolean; + disabled?: boolean; + disabledReason?: string; + singleInstance?: boolean; }>; supportLegend: { allowUnsupportedXblocks?: boolean; @@ -46,6 +49,7 @@ type ComponentTemplateData = { export interface AddComponentProps { isSplitTestType?: boolean; isUnitVerticalType?: boolean; + isGenericContainerType?: boolean; parentLocator: string; handleCreateNewCourseXBlock: ( args: object, @@ -64,6 +68,7 @@ const AddComponent = ({ isSplitTestType, isUnitVerticalType, isProblemBankType, + isGenericContainerType, addComponentTemplateData, handleCreateNewCourseXBlock, }: AddComponentProps) => { @@ -74,6 +79,10 @@ const AddComponent = ({ const [isOpenHtml, openHtml, closeHtml] = useToggle(false); const [isOpenOpenAssessment, openOpenAssessment, closeOpenAssessment] = useToggle(false); const { componentTemplates = {} } = useSelector(getCourseSectionVertical); + const courseVerticalChildren = useSelector(getCourseVerticalChildren); + const existingBlockTypes = new Set( + (courseVerticalChildren?.children ?? []).map((child: { blockType?: string; category?: string }) => child.blockType ?? child.category), + ); const blockId = addComponentTemplateData?.parentLocator || parentLocator; const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle(); const [isVideoSelectorModalOpen, showVideoSelectorModal, closeVideoSelectorModal] = useToggle(); @@ -215,10 +224,10 @@ const AddComponent = ({ } }; - if (isUnitVerticalType || isSplitTestType || isProblemBankType) { + if (isUnitVerticalType || isSplitTestType || isProblemBankType || isGenericContainerType) { return (
- {Object.keys(componentTemplates).length && isUnitVerticalType ? + {Object.keys(componentTemplates).length && (isUnitVerticalType || isGenericContainerType) ? ( <>
{intl.formatMessage(messages.title)}
@@ -253,7 +262,13 @@ const AddComponent = ({ isOpen: isOpenOpenAssessment, }; break; - default: + default: { + const firstTemplate = component.templates[0]; + const isSingleInstanceSatisfied = !!firstTemplate?.singleInstance + && existingBlockTypes.has(firstTemplate.category ?? type); + const isDisabled = !!firstTemplate?.disabled || isSingleInstanceSatisfied; + const disabledReason = firstTemplate?.disabledReason + ?? (isSingleInstanceSatisfied ? 'Only one instance of this component is allowed.' : undefined); return (
  • ); + } } + const allTemplatesDisabled = component.templates.every( + (t) => !!t.disabled, + ); + const firstDisabledReason = component.templates.find((t) => t.disabledReason)?.disabledReason; return ( ); })} diff --git a/src/course-unit/add-component/add-component-btn/index.jsx b/src/course-unit/add-component/add-component-btn/index.jsx index 05ca8fa953..785112e415 100644 --- a/src/course-unit/add-component/add-component-btn/index.jsx +++ b/src/course-unit/add-component/add-component-btn/index.jsx @@ -1,5 +1,7 @@ import PropTypes from 'prop-types'; -import { Badge, Button } from '@openedx/paragon'; +import { + Badge, Button, OverlayTrigger, Tooltip, +} from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; import messages from '../messages'; @@ -10,14 +12,17 @@ const AddComponentButton = ({ displayName, onClick, beta, + disabled, + disabledReason, }) => { const intl = useIntl(); - return ( + const button = ( ); + + if (disabled && disabledReason) { + return ( + {disabledReason}} + > + {button} + + ); + } + return button; }; AddComponentButton.defaultProps = { beta: false, + disabled: false, + disabledReason: null, }; - AddComponentButton.propTypes = { type: PropTypes.string.isRequired, displayName: PropTypes.string.isRequired, onClick: PropTypes.func.isRequired, beta: PropTypes.bool, + disabled: PropTypes.bool, + disabledReason: PropTypes.string, }; export default AddComponentButton; diff --git a/src/course-unit/add-component/add-component-modals/ComponentModalView.jsx b/src/course-unit/add-component/add-component-modals/ComponentModalView.jsx index 6ca35afe5e..93a86ea5d7 100644 --- a/src/course-unit/add-component/add-component-modals/ComponentModalView.jsx +++ b/src/course-unit/add-component/add-component-modals/ComponentModalView.jsx @@ -15,6 +15,8 @@ const ComponentModalView = ({ modalParams, handleCreateNewXBlock, isRequestedModalView, + disabled, + disabledReason, }) => { const intl = useIntl(); const dispatch = useDispatch(); @@ -37,9 +39,11 @@ const ComponentModalView = ({ const renderAddComponentButton = () => (
  • ); @@ -102,6 +106,8 @@ const ComponentModalView = ({ ComponentModalView.defaultProps = { isRequestedModalView: false, + disabled: false, + disabledReason: null, }; ComponentModalView.propTypes = { @@ -111,6 +117,9 @@ ComponentModalView.propTypes = { isOpen: PropTypes.bool, }).isRequired, handleCreateNewXBlock: PropTypes.func.isRequired, + isRequestedModalView: PropTypes.bool, + disabled: PropTypes.bool, + disabledReason: PropTypes.string, component: PropTypes.shape({ displayName: PropTypes.string.isRequired, category: PropTypes.string, @@ -129,7 +138,6 @@ ComponentModalView.propTypes = { showLegend: PropTypes.bool, }), }).isRequired, - isRequestedModalView: PropTypes.bool, }; export default ComponentModalView; diff --git a/src/course-unit/hooks.tsx b/src/course-unit/hooks.tsx index c7724e4b6a..e95a4a82a0 100644 --- a/src/course-unit/hooks.tsx +++ b/src/course-unit/hooks.tsx @@ -84,6 +84,9 @@ export const useCourseUnit = ({ COURSE_BLOCK_NAMES.legacyLibraryContent.id, COURSE_BLOCK_NAMES.itembank.id, ].includes(unitCategory); + // True for generic container XBlocks that are not one of the specially-handled types above. + // These containers can also host add-component buttons via the MFE native strip. + const isGenericContainerType = !isUnitVerticalType && !isSplitTestType && !isProblemBankType && !isUnitLegacyLibraryType && !!unitCategory; const headerNavigationsActions = { handleViewLive: () => { @@ -259,6 +262,7 @@ export const useCourseUnit = ({ isUnitLegacyLibraryType, isSplitTestType, isProblemBankType, + isGenericContainerType, sharedClipboardData, showPasteXBlock, showPasteUnit, From b37a0105b7e84869826c98a53a7bce5aa2b7c0cc Mon Sep 17 00:00:00 2001 From: salmannawaz Date: Tue, 12 May 2026 23:11:39 +0500 Subject: [PATCH 02/10] fix: formating issues fix --- src/course-unit/add-component/AddComponent.tsx | 4 +++- src/course-unit/add-component/add-component-btn/index.jsx | 5 ++++- src/course-unit/hooks.tsx | 3 ++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/course-unit/add-component/AddComponent.tsx b/src/course-unit/add-component/AddComponent.tsx index ae085ca14d..b4456e5c5b 100644 --- a/src/course-unit/add-component/AddComponent.tsx +++ b/src/course-unit/add-component/AddComponent.tsx @@ -81,7 +81,9 @@ const AddComponent = ({ const { componentTemplates = {} } = useSelector(getCourseSectionVertical); const courseVerticalChildren = useSelector(getCourseVerticalChildren); const existingBlockTypes = new Set( - (courseVerticalChildren?.children ?? []).map((child: { blockType?: string; category?: string }) => child.blockType ?? child.category), + (courseVerticalChildren?.children ?? []).map((child: { blockType?: string; category?: string; }) => + child.blockType ?? child.category + ), ); const blockId = addComponentTemplateData?.parentLocator || parentLocator; const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle(); diff --git a/src/course-unit/add-component/add-component-btn/index.jsx b/src/course-unit/add-component/add-component-btn/index.jsx index 785112e415..288cea526e 100644 --- a/src/course-unit/add-component/add-component-btn/index.jsx +++ b/src/course-unit/add-component/add-component-btn/index.jsx @@ -1,6 +1,9 @@ import PropTypes from 'prop-types'; import { - Badge, Button, OverlayTrigger, Tooltip, + Badge, + Button, + OverlayTrigger, + Tooltip, } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; diff --git a/src/course-unit/hooks.tsx b/src/course-unit/hooks.tsx index e95a4a82a0..972da8d97d 100644 --- a/src/course-unit/hooks.tsx +++ b/src/course-unit/hooks.tsx @@ -86,7 +86,8 @@ export const useCourseUnit = ({ ].includes(unitCategory); // True for generic container XBlocks that are not one of the specially-handled types above. // These containers can also host add-component buttons via the MFE native strip. - const isGenericContainerType = !isUnitVerticalType && !isSplitTestType && !isProblemBankType && !isUnitLegacyLibraryType && !!unitCategory; + const isGenericContainerType = !isUnitVerticalType && !isSplitTestType && !isProblemBankType && + !isUnitLegacyLibraryType && !!unitCategory; const headerNavigationsActions = { handleViewLive: () => { From d996b81f252fccce73d114fcd94e7310f5772f2d Mon Sep 17 00:00:00 2001 From: salmannawaz Date: Mon, 29 Jun 2026 23:35:20 +0500 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20address=20PR=20review=20comments?= =?UTF-8?q?=20=E2=80=94=20remove=20defaultProps,=20convert=20to=20TypeScri?= =?UTF-8?q?pt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace defaultProps with ES6 function defaults in AddComponentButton and ComponentModalView - Convert add-component-btn/index.jsx and ComponentModalView.jsx to TypeScript, removing propTypes - Add JSDoc comment explaining isGenericContainerType in AddComponentProps Co-Authored-By: Claude Sonnet 4.6 --- .../add-component/AddComponent.tsx | 5 ++ .../add-component/add-component-btn/index.tsx | 58 +++++++++++++ ...ntModalView.jsx => ComponentModalView.tsx} | 87 +++++++++---------- 3 files changed, 104 insertions(+), 46 deletions(-) create mode 100644 src/course-unit/add-component/add-component-btn/index.tsx rename src/course-unit/add-component/add-component-modals/{ComponentModalView.jsx => ComponentModalView.tsx} (68%) diff --git a/src/course-unit/add-component/AddComponent.tsx b/src/course-unit/add-component/AddComponent.tsx index b4456e5c5b..563546661e 100644 --- a/src/course-unit/add-component/AddComponent.tsx +++ b/src/course-unit/add-component/AddComponent.tsx @@ -49,6 +49,11 @@ type ComponentTemplateData = { export interface AddComponentProps { isSplitTestType?: boolean; isUnitVerticalType?: boolean; + /** + * True for generic container XBlocks that are not one of the specially-handled types + * (vertical, split_test, problem bank, or legacy library content). These containers + * can host the native add-component strip rendered by the MFE. + */ isGenericContainerType?: boolean; parentLocator: string; handleCreateNewCourseXBlock: ( diff --git a/src/course-unit/add-component/add-component-btn/index.tsx b/src/course-unit/add-component/add-component-btn/index.tsx new file mode 100644 index 0000000000..c19d7b0117 --- /dev/null +++ b/src/course-unit/add-component/add-component-btn/index.tsx @@ -0,0 +1,58 @@ +import { + Badge, + Button, + OverlayTrigger, + Tooltip, +} from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from '../messages'; +import AddComponentIcon from './AddComponentIcon'; + +interface AddComponentButtonProps { + type: string; + displayName: string; + onClick: () => void; + beta?: boolean; + disabled?: boolean; + disabledReason?: string | null; +} + +const AddComponentButton = ({ + type, + displayName, + onClick, + beta = false, + disabled = false, + disabledReason = null, +}: AddComponentButtonProps) => { + const intl = useIntl(); + + const button = ( + + ); + + if (disabled && disabledReason) { + return ( + {disabledReason}} + > + {button} + + ); + } + return button; +}; + +export default AddComponentButton; diff --git a/src/course-unit/add-component/add-component-modals/ComponentModalView.jsx b/src/course-unit/add-component/add-component-modals/ComponentModalView.tsx similarity index 68% rename from src/course-unit/add-component/add-component-modals/ComponentModalView.jsx rename to src/course-unit/add-component/add-component-modals/ComponentModalView.tsx index 93a86ea5d7..ea529d8d95 100644 --- a/src/course-unit/add-component/add-component-modals/ComponentModalView.jsx +++ b/src/course-unit/add-component/add-component-modals/ComponentModalView.tsx @@ -1,6 +1,5 @@ import { useState } from 'react'; import { useDispatch } from 'react-redux'; -import PropTypes from 'prop-types'; import { Form, OverlayTrigger, Tooltip } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; @@ -10,14 +9,44 @@ import AddComponentButton from '../add-component-btn'; import messages from '../messages'; import ModalContainer from './ModalContainer'; +interface ComponentTemplate { + boilerplateName?: string; + category?: string; + displayName: string; + supportLevel?: string | boolean; +} + +interface ComponentModalViewProps { + component: { + displayName: string; + category?: string; + type: string; + templates: ComponentTemplate[]; + supportLegend: { + allowUnsupportedXblocks?: boolean; + documentationLabel?: string; + showLegend?: boolean; + }; + }; + modalParams: { + open: () => void; + close: () => void; + isOpen: boolean; + }; + handleCreateNewXBlock: (type: string, moduleName?: string) => void; + isRequestedModalView?: boolean; + disabled?: boolean; + disabledReason?: string | null; +} + const ComponentModalView = ({ component, modalParams, handleCreateNewXBlock, - isRequestedModalView, - disabled, - disabledReason, -}) => { + isRequestedModalView = false, + disabled = false, + disabledReason = null, +}: ComponentModalViewProps) => { const intl = useIntl(); const dispatch = useDispatch(); const [moduleTitle, setModuleTitle] = useState(''); @@ -39,7 +68,7 @@ const ComponentModalView = ({ const renderAddComponentButton = () => (
  • {templates.map((componentTemplate) => { const value = componentTemplate.boilerplateName || componentTemplate.category; - const isDisplaySupportLabel = supportLegend.showLegend && supportLabels[componentTemplate.supportLevel]; + const { supportLevel } = componentTemplate; + const isDisplaySupportLabel = supportLegend.showLegend + && typeof supportLevel === 'string' && supportLabels[supportLevel]; return (
    {componentTemplate.displayName} - {isDisplaySupportLabel && ( + {isDisplaySupportLabel && typeof supportLevel === 'string' && ( - {supportLabels[componentTemplate.supportLevel].tooltip} + {supportLabels[supportLevel].tooltip} } > - {supportLabels[componentTemplate.supportLevel].label} + {supportLabels[supportLevel].label} )} @@ -104,40 +135,4 @@ const ComponentModalView = ({ ); }; -ComponentModalView.defaultProps = { - isRequestedModalView: false, - disabled: false, - disabledReason: null, -}; - -ComponentModalView.propTypes = { - modalParams: PropTypes.shape({ - open: PropTypes.func, - close: PropTypes.func, - isOpen: PropTypes.bool, - }).isRequired, - handleCreateNewXBlock: PropTypes.func.isRequired, - isRequestedModalView: PropTypes.bool, - disabled: PropTypes.bool, - disabledReason: PropTypes.string, - component: PropTypes.shape({ - displayName: PropTypes.string.isRequired, - category: PropTypes.string, - type: PropTypes.string.isRequired, - templates: PropTypes.arrayOf( - PropTypes.shape({ - boilerplateName: PropTypes.string, - category: PropTypes.string, - displayName: PropTypes.string.isRequired, - supportLevel: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), - }), - ), - supportLegend: PropTypes.shape({ - allowUnsupportedXblocks: PropTypes.bool, - documentationLabel: PropTypes.string, - showLegend: PropTypes.bool, - }), - }).isRequired, -}; - export default ComponentModalView; From d4644e856010fb48735e0ffea2277467359a3210 Mon Sep 17 00:00:00 2001 From: salmannawaz Date: Tue, 30 Jun 2026 14:07:42 +0500 Subject: [PATCH 04/10] fix: remove add-component-btn/index.jsx after TypeScript conversion Co-Authored-By: Claude Sonnet 4.6 --- .../add-component/add-component-btn/index.jsx | 64 ------------------- 1 file changed, 64 deletions(-) delete mode 100644 src/course-unit/add-component/add-component-btn/index.jsx diff --git a/src/course-unit/add-component/add-component-btn/index.jsx b/src/course-unit/add-component/add-component-btn/index.jsx deleted file mode 100644 index 288cea526e..0000000000 --- a/src/course-unit/add-component/add-component-btn/index.jsx +++ /dev/null @@ -1,64 +0,0 @@ -import PropTypes from 'prop-types'; -import { - Badge, - Button, - OverlayTrigger, - Tooltip, -} from '@openedx/paragon'; -import { useIntl } from '@edx/frontend-platform/i18n'; - -import messages from '../messages'; -import AddComponentIcon from './AddComponentIcon'; - -const AddComponentButton = ({ - type, - displayName, - onClick, - beta, - disabled, - disabledReason, -}) => { - const intl = useIntl(); - - const button = ( - - ); - - if (disabled && disabledReason) { - return ( - {disabledReason}} - > - {button} - - ); - } - return button; -}; - -AddComponentButton.defaultProps = { - beta: false, - disabled: false, - disabledReason: null, -}; -AddComponentButton.propTypes = { - type: PropTypes.string.isRequired, - displayName: PropTypes.string.isRequired, - onClick: PropTypes.func.isRequired, - beta: PropTypes.bool, - disabled: PropTypes.bool, - disabledReason: PropTypes.string, -}; - -export default AddComponentButton; From 98cfa133a862a081fd5bb2ae9744a2b9b1e38ba9 Mon Sep 17 00:00:00 2001 From: salmannawaz Date: Thu, 2 Jul 2026 01:15:50 +0500 Subject: [PATCH 05/10] fix: handle unknown block types in handleCreateNewXBlock Add a default case to handleCreateNewXBlock that calls handleCreateNewCourseXBlock with the block's type and category. This allows custom/third-party block types declared via StudioContainerWithNestedXBlocksMixin.allowed_nested_blocks (e.g. ranged-value-freeform) to be created when the author clicks their individual Add New Component button, instead of silently doing nothing. Co-Authored-By: Claude Sonnet 4.6 --- src/course-unit/add-component/AddComponent.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/course-unit/add-component/AddComponent.tsx b/src/course-unit/add-component/AddComponent.tsx index 563546661e..2a929e205e 100644 --- a/src/course-unit/add-component/AddComponent.tsx +++ b/src/course-unit/add-component/AddComponent.tsx @@ -228,6 +228,10 @@ const AddComponent = ({ }); break; default: + // Generic handler for container-specific block types declared via + // StudioContainerWithNestedXBlocksMixin.allowed_nested_blocks that are + // not one of the MFE's built-in component types (html, video, problem…). + handleCreateNewCourseXBlock({ type, category: type, parentLocator: blockId }); } }; From 306dacc306e3912cbd045b70131544cac3e3b768 Mon Sep 17 00:00:00 2001 From: salmannawaz Date: Fri, 3 Jul 2026 11:53:22 +0500 Subject: [PATCH 06/10] test: update AddComponent test to reflect new default XBlock handler behavior Co-Authored-By: Claude Sonnet 4.6 --- src/course-unit/add-component/AddComponent.test.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/course-unit/add-component/AddComponent.test.tsx b/src/course-unit/add-component/AddComponent.test.tsx index a44d44917b..f29094ac1f 100644 --- a/src/course-unit/add-component/AddComponent.test.tsx +++ b/src/course-unit/add-component/AddComponent.test.tsx @@ -158,7 +158,7 @@ describe('', () => { }); }); - it('handleCreateNewCourseXblock does\'t call with custom component create button is clicked', async () => { + it('calls handleCreateNewCourseXblock with correct parameters when custom component create button is clicked', async () => { axiosMock .onGet(getCourseSectionVerticalApiUrl(blockId)) .reply(200, { @@ -181,7 +181,11 @@ describe('', () => { }); await user.click(customComponentButton); - expect(handleCreateNewCourseXBlockMock).not.toHaveBeenCalled(); + expect(handleCreateNewCourseXBlockMock).toHaveBeenCalledWith({ + type: 'custom', + category: 'custom', + parentLocator: blockId, + }); }); it('calls handleCreateNewCourseXblock with correct parameters when Discussion xblock create button is clicked', async () => { From e59882b338d1e7ffdc9747111c317e825cfbf289 Mon Sep 17 00:00:00 2001 From: salmannawaz Date: Thu, 9 Jul 2026 12:32:54 +0500 Subject: [PATCH 07/10] test: add missing coverage tests for generic container and disabled component features Co-Authored-By: Claude Sonnet 4.6 --- src/course-unit/CourseUnit.test.tsx | 27 ++++ .../add-component/AddComponent.test.tsx | 151 +++++++++++++++++- 2 files changed, 176 insertions(+), 2 deletions(-) diff --git a/src/course-unit/CourseUnit.test.tsx b/src/course-unit/CourseUnit.test.tsx index a72ed30e3b..c742f3ab3e 100644 --- a/src/course-unit/CourseUnit.test.tsx +++ b/src/course-unit/CourseUnit.test.tsx @@ -2546,6 +2546,33 @@ describe('', () => { it('opens legacy edit modal on edit button click', checkLegacyEditModalOnEditMessage); }); + describe('Generic Container XBlock page', () => { + beforeEach(async () => { + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + xblock: { + ...courseSectionVerticalMock.xblock, + category: 'custom_container', + }, + xblock_info: { + ...courseSectionVerticalMock.xblock_info, + category: 'custom_container', + }, + }); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + }); + + it('renders the add-component strip for a generic container XBlock type', async () => { + render(); + + expect( + await screen.findByRole('heading', { name: addComponentMessages.title.defaultMessage }), + ).toBeInTheDocument(); + }); + }); + it('renders and navigates to the new HTML XBlock editor after xblock duplicating', async () => { const updatedCourseVerticalChildrenMock = JSON.parse(JSON.stringify(courseVerticalChildrenMock)); // Convert the second child from drag and drop to HTML: diff --git a/src/course-unit/add-component/AddComponent.test.tsx b/src/course-unit/add-component/AddComponent.test.tsx index f29094ac1f..e1d2b5140b 100644 --- a/src/course-unit/add-component/AddComponent.test.tsx +++ b/src/course-unit/add-component/AddComponent.test.tsx @@ -13,8 +13,8 @@ import { initializeMocks, } from '../../testUtils'; import { executeThunk } from '../../utils'; -import { fetchCourseSectionVerticalData } from '../data/thunk'; -import { getCourseSectionVerticalApiUrl } from '../data/api'; +import { fetchCourseSectionVerticalData, fetchCourseVerticalChildrenData } from '../data/thunk'; +import { getCourseSectionVerticalApiUrl, getCourseVerticalChildrenApiUrl } from '../data/api'; import { courseSectionVerticalMock } from '../__mocks__'; import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants'; import AddComponent, { AddComponentProps } from './AddComponent'; @@ -569,6 +569,153 @@ describe('', () => { }); }); + it('renders add component strip when isGenericContainerType is true', () => { + const { getByRole } = renderComponent({ isGenericContainerType: true, isUnitVerticalType: false, parentLocator: blockId, handleCreateNewCourseXBlock: handleCreateNewCourseXBlockMock }); + + expect(getByRole('heading', { name: messages.title.defaultMessage })).toBeInTheDocument(); + }); + + it('disables button and shows tooltip when singleInstance block already exists in vertical', async () => { + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + component_templates: [ + { + type: 'custom', + templates: [{ display_name: 'Custom', category: 'custom', single_instance: true }], + display_name: 'Custom', + support_legend: {}, + }, + ], + }); + axiosMock + .onGet(getCourseVerticalChildrenApiUrl(blockId)) + .reply(200, { + children: [{ block_type: 'custom', id: 'block-v1:test', name: 'Custom Block' }], + is_published: false, + can_paste_component: false, + }); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + await executeThunk(fetchCourseVerticalChildrenData(blockId, false), store.dispatch); + + const user = userEvent.setup(); + const { getByRole } = renderComponent(); + + const customBtn = getByRole('button', { + name: new RegExp(`${messages.buttonText.defaultMessage} Custom`, 'i'), + }); + expect(customBtn).toBeDisabled(); + + await user.hover(customBtn.closest('span') ?? customBtn); + expect(await screen.findByText('Only one instance of this component is allowed.')).toBeInTheDocument(); + }); + + it('does not disable singleInstance button when block does not yet exist in vertical', async () => { + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + component_templates: [ + { + type: 'custom', + templates: [{ display_name: 'Custom', category: 'custom', single_instance: true }], + display_name: 'Custom', + support_legend: {}, + }, + ], + }); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + + const { getByRole } = renderComponent(); + + const customBtn = getByRole('button', { + name: new RegExp(`${messages.buttonText.defaultMessage} Custom`, 'i'), + }); + expect(customBtn).not.toBeDisabled(); + }); + + it('disables button without tooltip when template is disabled but has no disabledReason', async () => { + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + component_templates: [ + { + type: 'custom', + templates: [{ display_name: 'Custom', category: 'custom', disabled: true }], + display_name: 'Custom', + support_legend: {}, + }, + ], + }); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + + const user = userEvent.setup(); + const { getByRole } = renderComponent(); + + const customBtn = getByRole('button', { + name: new RegExp(`${messages.buttonText.defaultMessage} Custom`, 'i'), + }); + expect(customBtn).toBeDisabled(); + + // No OverlayTrigger wraps the button, so hovering shows no tooltip + await user.hover(customBtn); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it('disables the modal trigger button when all templates of a component type are disabled', async () => { + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + component_templates: [ + { + type: 'html', + templates: [ + { + display_name: 'Text', + category: 'html', + disabled: true, + disabledReason: 'Text components are not allowed here.', + }, + ], + display_name: 'Text', + support_legend: {}, + }, + ], + }); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + + const { getByRole } = renderComponent(); + + const textBtn = getByRole('button', { + name: new RegExp(`${messages.buttonText.defaultMessage} Text`, 'i'), + }); + expect(textBtn).toBeDisabled(); + }); + + it('opens single component picker on showSingleComponentPicker window message', async () => { + const user = userEvent.setup(); + renderComponent(); + act(() => { + window.dispatchEvent(new MessageEvent('message', { + data: { + type: messageTypes.showSingleComponentPicker, + payload: { usageId: 'block-v1:test+type@vertical+block@abc' }, + }, + })); + }); + + // The single component picker modal should open + const dummyBtn = await screen.findByRole('button', { name: 'Dummy button' }); + await user.click(dummyBtn); + + expect(handleCreateNewCourseXBlockMock).toHaveBeenCalledWith( + expect.objectContaining({ type: COMPONENT_TYPES.libraryV2 }), + ); + }); + describe('component support label', () => { it('component support label is hidden if component support legend is disabled', async () => { const supportLevels = ['fs', 'ps']; From bd1b85cc952e61efade15087fe0fce659aafd9bb Mon Sep 17 00:00:00 2001 From: salmannawaz Date: Thu, 9 Jul 2026 12:43:16 +0500 Subject: [PATCH 08/10] style: fix dprint formatting in AddComponent.test.tsx Co-Authored-By: Claude Sonnet 4.6 --- .../add-component/AddComponent.test.tsx | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/course-unit/add-component/AddComponent.test.tsx b/src/course-unit/add-component/AddComponent.test.tsx index e1d2b5140b..c18df88ac5 100644 --- a/src/course-unit/add-component/AddComponent.test.tsx +++ b/src/course-unit/add-component/AddComponent.test.tsx @@ -570,7 +570,12 @@ describe('', () => { }); it('renders add component strip when isGenericContainerType is true', () => { - const { getByRole } = renderComponent({ isGenericContainerType: true, isUnitVerticalType: false, parentLocator: blockId, handleCreateNewCourseXBlock: handleCreateNewCourseXBlockMock }); + const { getByRole } = renderComponent({ + isGenericContainerType: true, + isUnitVerticalType: false, + parentLocator: blockId, + handleCreateNewCourseXBlock: handleCreateNewCourseXBlockMock, + }); expect(getByRole('heading', { name: messages.title.defaultMessage })).toBeInTheDocument(); }); @@ -699,12 +704,14 @@ describe('', () => { const user = userEvent.setup(); renderComponent(); act(() => { - window.dispatchEvent(new MessageEvent('message', { - data: { - type: messageTypes.showSingleComponentPicker, - payload: { usageId: 'block-v1:test+type@vertical+block@abc' }, - }, - })); + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: messageTypes.showSingleComponentPicker, + payload: { usageId: 'block-v1:test+type@vertical+block@abc' }, + }, + }), + ); }); // The single component picker modal should open From c66dd98676cda09bb3bc8b07a14a0eb4dc7e06e9 Mon Sep 17 00:00:00 2001 From: salmannawaz Date: Thu, 9 Jul 2026 13:13:43 +0500 Subject: [PATCH 09/10] test: cover return null branch when no container type prop is set Co-Authored-By: Claude Sonnet 4.6 --- src/course-unit/add-component/AddComponent.test.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/course-unit/add-component/AddComponent.test.tsx b/src/course-unit/add-component/AddComponent.test.tsx index c18df88ac5..b4328bdcdb 100644 --- a/src/course-unit/add-component/AddComponent.test.tsx +++ b/src/course-unit/add-component/AddComponent.test.tsx @@ -580,6 +580,16 @@ describe('', () => { expect(getByRole('heading', { name: messages.title.defaultMessage })).toBeInTheDocument(); }); + it('returns null when no container type prop is set', () => { + const { queryByRole } = renderComponent({ + isUnitVerticalType: false, + parentLocator: blockId, + handleCreateNewCourseXBlock: handleCreateNewCourseXBlockMock, + }); + + expect(queryByRole('heading', { name: messages.title.defaultMessage })).not.toBeInTheDocument(); + }); + it('disables button and shows tooltip when singleInstance block already exists in vertical', async () => { axiosMock .onGet(getCourseSectionVerticalApiUrl(blockId)) From 70536b9c1f81270f619723bc1a2c40e8ce056238 Mon Sep 17 00:00:00 2001 From: salmannawaz Date: Thu, 23 Jul 2026 12:05:14 +0500 Subject: [PATCH 10/10] test: cover disabled default value in AddComponentButton Co-Authored-By: Claude Sonnet 4.6 --- src/course-unit/add-component/AddComponent.test.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/course-unit/add-component/AddComponent.test.tsx b/src/course-unit/add-component/AddComponent.test.tsx index b4328bdcdb..b10a864f2d 100644 --- a/src/course-unit/add-component/AddComponent.test.tsx +++ b/src/course-unit/add-component/AddComponent.test.tsx @@ -18,6 +18,7 @@ import { getCourseSectionVerticalApiUrl, getCourseVerticalChildrenApiUrl } from import { courseSectionVerticalMock } from '../__mocks__'; import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants'; import AddComponent, { AddComponentProps } from './AddComponent'; +import AddComponentButton from './add-component-btn'; import messages from './messages'; import { IframeProvider } from '../../generic/hooks/context/iFrameContext'; import { messageTypes } from '../constants'; @@ -733,6 +734,17 @@ describe('', () => { ); }); + it('renders AddComponentButton as enabled by default when disabled prop is omitted', () => { + render( + {}} + />, + ); + expect(screen.getByRole('button')).not.toBeDisabled(); + }); + describe('component support label', () => { it('component support label is hidden if component support legend is disabled', async () => { const supportLevels = ['fs', 'ps'];