Skip to content
Merged
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
27 changes: 27 additions & 0 deletions src/course-unit/CourseUnit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2546,6 +2546,33 @@ describe('<CourseUnit />', () => {
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(<RootWrapper />);

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:
Expand Down
2 changes: 2 additions & 0 deletions src/course-unit/CourseUnit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ const CourseUnit = () => {
isUnitLegacyLibraryType,
isSplitTestType,
isProblemBankType,
isGenericContainerType,
staticFileNotices,
currentlyVisibleToStudents,
unitXBlockActions,
Expand Down Expand Up @@ -375,6 +376,7 @@ const CourseUnit = () => {
isSplitTestType={isSplitTestType}
isUnitVerticalType={isUnitVerticalType}
isProblemBankType={isProblemBankType}
isGenericContainerType={isGenericContainerType}
Comment thread
bradenmacdonald marked this conversation as resolved.
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
addComponentTemplateData={addComponentTemplateData}
/>
Expand Down
188 changes: 184 additions & 4 deletions src/course-unit/add-component/AddComponent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ 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';
import AddComponentButton from './add-component-btn';
import messages from './messages';
import { IframeProvider } from '../../generic/hooks/context/iFrameContext';
import { messageTypes } from '../constants';
Expand Down Expand Up @@ -158,7 +159,7 @@ describe('<AddComponent />', () => {
});
});

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, {
Expand All @@ -181,7 +182,11 @@ describe('<AddComponent />', () => {
});

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 () => {
Expand Down Expand Up @@ -565,6 +570,181 @@ describe('<AddComponent />', () => {
});
});

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('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))
.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 }),
);
});

it('renders AddComponentButton as enabled by default when disabled prop is omitted', () => {
render(
<AddComponentButton
type="html"
displayName="Text"
onClick={() => {}}
/>,
);
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'];
Expand Down
43 changes: 39 additions & 4 deletions src/course-unit/add-component/AddComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -35,6 +35,9 @@ type ComponentTemplateData = {
category?: string;
displayName: string;
supportLevel?: string | boolean;
disabled?: boolean;
disabledReason?: string;
singleInstance?: boolean;
}>;
supportLegend: {
allowUnsupportedXblocks?: boolean;
Expand All @@ -46,6 +49,12 @@ 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;
Comment thread
bradenmacdonald marked this conversation as resolved.
parentLocator: string;
handleCreateNewCourseXBlock: (
args: object,
Expand All @@ -64,6 +73,7 @@ const AddComponent = ({
isSplitTestType,
isUnitVerticalType,
isProblemBankType,
isGenericContainerType,
addComponentTemplateData,
handleCreateNewCourseXBlock,
}: AddComponentProps) => {
Expand All @@ -74,6 +84,12 @@ 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();
Expand Down Expand Up @@ -212,13 +228,17 @@ 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 });
}
};

if (isUnitVerticalType || isSplitTestType || isProblemBankType) {
if (isUnitVerticalType || isSplitTestType || isProblemBankType || isGenericContainerType) {
return (
<div className="py-4">
{Object.keys(componentTemplates).length && isUnitVerticalType ?
{Object.keys(componentTemplates).length && (isUnitVerticalType || isGenericContainerType) ?
(
<>
<h5 className="h3 mb-4 text-center">{intl.formatMessage(messages.title)}</h5>
Expand Down Expand Up @@ -253,25 +273,40 @@ 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 (
<li key={type}>
<AddComponentButton
onClick={() => handleCreateNewXBlock(type)}
displayName={displayName}
type={type}
beta={beta}
disabled={isDisabled}
disabledReason={disabledReason}
/>
</li>
);
}
}

const allTemplatesDisabled = component.templates.every(
(t) => !!t.disabled,
);
const firstDisabledReason = component.templates.find((t) => t.disabledReason)?.disabledReason;
return (
<ComponentModalView
key={type}
component={component}
handleCreateNewXBlock={handleCreateNewXBlock}
modalParams={modalParams}
disabled={allTemplatesDisabled}
disabledReason={allTemplatesDisabled ? firstDisabledReason : undefined}
/>
);
})}
Expand Down
Loading