feat: [FC-0070] rendering split test content in unit page (#1492)

Introduces functionality to display Split Test Content within the new Unit page interface.
This commit is contained in:
Ihor Romaniuk
2025-04-01 16:37:14 +02:00
committed by GitHub
parent 272e30f1b1
commit 3685dbd6a1
29 changed files with 775 additions and 258 deletions

View File

@@ -59,6 +59,7 @@ export const COURSE_BLOCK_NAMES = ({
sequential: { id: 'sequential', name: 'Subsection' },
vertical: { id: 'vertical', name: 'Unit' },
libraryContent: { id: 'library_content', name: 'Library content' },
splitTest: { id: 'split_test', name: 'Split Test' },
component: { id: 'component', name: 'Component' },
});

View File

@@ -28,6 +28,7 @@ import Breadcrumbs from './breadcrumbs/Breadcrumbs';
import HeaderNavigations from './header-navigations/HeaderNavigations';
import Sequence from './course-sequence';
import Sidebar from './sidebar';
import SplitTestSidebarInfo from './sidebar/SplitTestSidebarInfo';
import { useCourseUnit, useLayoutGrid, useScrollToLastPosition } from './hooks';
import messages from './messages';
import PublishControls from './sidebar/PublishControls';
@@ -52,6 +53,7 @@ const CourseUnit = ({ courseId }) => {
isTitleEditFormOpen,
isUnitVerticalType,
isUnitLibraryType,
isSplitTestType,
staticFileNotices,
currentlyVisibleToStudents,
unitXBlockActions,
@@ -72,6 +74,7 @@ const CourseUnit = ({ courseId }) => {
handleRollbackMovedXBlock,
handleCloseXBlockMovedAlert,
handleNavigateToTargetUnit,
addComponentTemplateData,
} = useCourseUnit({ courseId, blockId });
const layoutGrid = useLayoutGrid(unitCategory, isUnitLibraryType);
@@ -155,7 +158,7 @@ const CourseUnit = ({ courseId }) => {
)}
headerActions={(
<HeaderNavigations
unitCategory={unitCategory}
category={unitCategory}
headerNavigationsActions={headerNavigationsActions}
/>
)}
@@ -188,16 +191,18 @@ const CourseUnit = ({ courseId }) => {
<XBlockContainerIframe
courseId={courseId}
blockId={blockId}
isUnitVerticalType={isUnitVerticalType}
unitXBlockActions={unitXBlockActions}
courseVerticalChildren={courseVerticalChildren.children}
handleConfigureSubmit={handleConfigureSubmit}
/>
{isUnitVerticalType && (
<AddComponent
blockId={blockId}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
/>
)}
<AddComponent
parentLocator={blockId}
isSplitTestType={isSplitTestType}
isUnitVerticalType={isUnitVerticalType}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
addComponentTemplateData={addComponentTemplateData}
/>
{showPasteXBlock && canPasteComponent && isUnitVerticalType && (
<PasteComponent
clipboardData={sharedClipboardData}
@@ -232,6 +237,11 @@ const CourseUnit = ({ courseId }) => {
</Sidebar>
</>
)}
{isSplitTestType && (
<Sidebar data-testid="course-split-test-sidebar">
<SplitTestSidebarInfo />
</Sidebar>
)}
</Stack>
</Layout.Element>
</Layout>

View File

@@ -5,6 +5,7 @@
@import "./header-title/HeaderTitle";
@import "./move-modal";
@import "./preview-changes";
@import "./xblock-container-iframe";
.course-unit {
min-width: 900px;

View File

@@ -48,10 +48,8 @@ import { executeThunk } from '../utils';
import { IFRAME_FEATURE_POLICY } from '../constants';
import pasteComponentMessages from '../generic/clipboard/paste-component/messages';
import pasteNotificationsMessages from './clipboard/paste-notification/messages';
import headerNavigationsMessages from './header-navigations/messages';
import headerTitleMessages from './header-title/messages';
import courseSequenceMessages from './course-sequence/messages';
import sidebarMessages from './sidebar/messages';
import { extractCourseUnitId } from './sidebar/utils';
import CourseUnit from './CourseUnit';
@@ -64,6 +62,8 @@ import { messageTypes, PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants
import { IframeProvider } from './context/iFrameContext';
import moveModalMessages from './move-modal/messages';
import xblockContainerIframeMessages from './xblock-container-iframe/messages';
import headerNavigationsMessages from './header-navigations/messages';
import sidebarMessages from './sidebar/messages';
import messages from './messages';
let axiosMock;
@@ -196,7 +196,7 @@ describe('<CourseUnit />', () => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toHaveAttribute('src', `${getConfig().STUDIO_BASE_URL}/container_embed/${blockId}`);
expect(iframe).toHaveAttribute('allow', IFRAME_FEATURE_POLICY);
expect(iframe).toHaveAttribute('style', 'width: 100%; height: 0px;');
expect(iframe).toHaveAttribute('style', 'height: 0px;');
expect(iframe).toHaveAttribute('scrolling', 'no');
expect(iframe).toHaveAttribute('referrerpolicy', 'origin');
expect(iframe).toHaveAttribute('loading', 'lazy');
@@ -209,11 +209,11 @@ describe('<CourseUnit />', () => {
await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toHaveAttribute('style', 'width: 100%; height: 0px;');
expect(iframe).toHaveAttribute('style', 'height: 0px;');
simulatePostMessageEvent(messageTypes.toggleCourseXBlockDropdown, {
courseXBlockDropdownHeight: 200,
});
expect(iframe).toHaveAttribute('style', 'width: 100%; height: 200px;');
expect(iframe).toHaveAttribute('style', 'height: 200px;');
});
});
@@ -1821,7 +1821,7 @@ describe('<CourseUnit />', () => {
});
describe('XBlock restrict access', () => {
it('opens xblock restrict access modal successfully', () => {
it('opens xblock restrict access modal successfully', async () => {
const {
getByTitle, getByTestId,
} = render(<RootWrapper />);
@@ -1830,7 +1830,7 @@ describe('<CourseUnit />', () => {
const modalCancelBtnText = configureModalMessages.cancelButton.defaultMessage;
const modalSaveBtnText = configureModalMessages.saveButton.defaultMessage;
waitFor(() => {
await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
const usageId = courseVerticalChildrenMock.children[0].block_id;
expect(iframe).toBeInTheDocument();
@@ -1840,7 +1840,7 @@ describe('<CourseUnit />', () => {
});
});
waitFor(() => {
await waitFor(() => {
const configureModal = getByTestId('configure-modal');
expect(within(configureModal).getByText(modalSubtitleText)).toBeInTheDocument();
@@ -1854,7 +1854,7 @@ describe('<CourseUnit />', () => {
getByTitle, queryByTestId, getByTestId,
} = render(<RootWrapper />);
waitFor(() => {
await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.manageXBlockAccess, {
@@ -1862,7 +1862,7 @@ describe('<CourseUnit />', () => {
});
});
waitFor(() => {
await waitFor(() => {
const configureModal = getByTestId('configure-modal');
expect(configureModal).toBeInTheDocument();
userEvent.click(within(configureModal).getByRole('button', {
@@ -1883,85 +1883,104 @@ describe('<CourseUnit />', () => {
.reply(200, { dummy: 'value' });
const {
getByTitle, getByRole, getByTestId,
getByTitle, getByRole, getByTestId, queryByTestId,
} = render(<RootWrapper />);
const accessGroupName1 = userPartitionInfoFormatted.selectablePartitions[0].groups[0].name;
const accessGroupName2 = userPartitionInfoFormatted.selectablePartitions[0].groups[1].name;
waitFor(() => {
await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toBeInTheDocument();
});
await act(async () => {
simulatePostMessageEvent(messageTypes.manageXBlockAccess, {
usageId: courseVerticalChildrenMock.children[0].block_id,
});
});
waitFor(() => {
const configureModal = getByTestId('configure-modal');
expect(configureModal).toBeInTheDocument();
const configureModal = await waitFor(() => getByTestId('configure-modal'));
expect(configureModal).toBeInTheDocument();
expect(within(configureModal).queryByText(accessGroupName1)).not.toBeInTheDocument();
expect(within(configureModal).queryByText(accessGroupName2)).not.toBeInTheDocument();
expect(within(configureModal).queryByText(accessGroupName1)).not.toBeInTheDocument();
expect(within(configureModal).queryByText(accessGroupName2)).not.toBeInTheDocument();
const restrictAccessSelect = getByRole('combobox', {
name: configureModalMessages.restrictAccessTo.defaultMessage,
});
userEvent.selectOptions(restrictAccessSelect, '0');
// eslint-disable-next-line array-callback-return
userPartitionInfoFormatted.selectablePartitions[0].groups.map((group) => {
expect(within(configureModal).getByRole('checkbox', { name: group.name })).not.toBeChecked();
expect(within(configureModal).queryByText(group.name)).toBeInTheDocument();
});
const group1Checkbox = within(configureModal).getByRole('checkbox', { name: accessGroupName1 });
userEvent.click(group1Checkbox);
expect(group1Checkbox).toBeChecked();
const saveModalBtnText = within(configureModal).getByRole('button', {
name: configureModalMessages.saveButton.defaultMessage,
});
expect(saveModalBtnText).toBeInTheDocument();
userEvent.click(saveModalBtnText);
expect(handleConfigureSubmitMock).toHaveBeenCalledTimes(1);
const restrictAccessSelect = getByRole('combobox', {
name: configureModalMessages.restrictAccessTo.defaultMessage,
});
await userEvent.selectOptions(restrictAccessSelect, '0');
await waitFor(() => {
userPartitionInfoFormatted.selectablePartitions[0].groups.forEach((group) => {
const checkbox = within(configureModal).getByRole('checkbox', { name: group.name });
expect(checkbox).not.toBeChecked();
expect(checkbox).toBeInTheDocument();
});
});
const group1Checkbox = within(configureModal).getByRole('checkbox', { name: accessGroupName1 });
await userEvent.click(group1Checkbox);
expect(group1Checkbox).toBeChecked();
const saveModalBtnText = within(configureModal).getByRole('button', {
name: configureModalMessages.saveButton.defaultMessage,
});
expect(saveModalBtnText).toBeInTheDocument();
await userEvent.click(saveModalBtnText);
await waitFor(() => {
expect(axiosMock.history.post.length).toBeGreaterThan(0);
expect(axiosMock.history.post[0].url).toBe(getXBlockBaseApiUrl(id));
});
expect(queryByTestId('configure-modal')).not.toBeInTheDocument();
});
});
it('renders and navigates to the new HTML XBlock editor after xblock duplicating', async () => {
const { getByTitle } = render(<RootWrapper />);
const updatedCourseVerticalChildrenMock = JSON.parse(JSON.stringify(courseVerticalChildrenMock));
const targetBlockId = updatedCourseVerticalChildrenMock.children[1].block_id;
updatedCourseVerticalChildrenMock.children = updatedCourseVerticalChildrenMock.children
.map((child) => (child.block_id === targetBlockId
? { ...child, block_type: 'html' }
: child));
axiosMock
.onGet(getCourseVerticalChildrenApiUrl(blockId))
.reply(200, updatedCourseVerticalChildrenMock);
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
const checkLegacyEditModalOnEditMessage = async () => {
const { getByTitle, getByTestId } = render(<RootWrapper />);
await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.currentXBlockId, {
id: targetBlockId,
});
const editButton = getByTestId('header-edit-button');
expect(editButton).toBeInTheDocument();
const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument();
userEvent.click(editButton);
});
};
const checkRenderVisibilityModal = async (headingMessageId) => {
const { getByRole, getByTestId } = render(<RootWrapper />);
let configureModal;
let restrictAccessSelect;
await waitFor(() => {
const headerConfigureBtn = getByRole('button', { name: /settings/i });
expect(headerConfigureBtn).toBeInTheDocument();
userEvent.click(headerConfigureBtn);
});
waitFor(() => {
simulatePostMessageEvent(messageTypes.duplicateXBlock, {});
simulatePostMessageEvent(messageTypes.newXBlockEditor, {});
expect(mockedUsedNavigate)
.toHaveBeenCalledWith(`/course/${courseId}/editor/html/${targetBlockId}`, { replace: true });
await waitFor(() => {
configureModal = getByTestId('configure-modal');
restrictAccessSelect = within(configureModal)
.getByRole('combobox', { name: configureModalMessages.restrictAccessTo.defaultMessage });
expect(within(configureModal)
.getByRole('heading', { name: configureModalMessages[headingMessageId].defaultMessage })).toBeInTheDocument();
expect(within(configureModal)
.queryByText(configureModalMessages.unitVisibility.defaultMessage)).not.toBeInTheDocument();
expect(within(configureModal)
.getByText(configureModalMessages.restrictAccessTo.defaultMessage)).toBeInTheDocument();
expect(restrictAccessSelect).toBeInTheDocument();
expect(restrictAccessSelect).toHaveValue('-1');
});
});
const modalSaveBtn = within(configureModal)
.getByRole('button', { name: configureModalMessages.saveButton.defaultMessage });
userEvent.click(modalSaveBtn);
};
describe('Library Content page', () => {
const newUnitId = '12345';
@@ -1982,6 +2001,20 @@ describe('<CourseUnit />', () => {
},
});
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.reply(200, {
...courseUnitIndexMock,
category: 'library_content',
ancestor_info: {
...courseUnitIndexMock.ancestor_info,
child_info: {
...courseUnitIndexMock.ancestor_info.child_info,
category: 'library_content',
},
},
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
});
it('navigates to library content page on receive window event', async () => {
@@ -2020,5 +2053,171 @@ describe('<CourseUnit />', () => {
expect(queryByRole('heading', { name: /unit location/i })).not.toBeInTheDocument();
});
});
it('should display visibility modal correctly', async () => (
checkRenderVisibilityModal('libraryContentAccess')
));
it('opens legacy edit modal on edit button click', checkLegacyEditModalOnEditMessage);
});
describe('Split Test Content page', () => {
const newUnitId = '12345';
const sequenceId = courseSectionVerticalMock.subsection_location;
beforeEach(async () => {
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, {
...courseSectionVerticalMock,
xblock: {
...courseSectionVerticalMock.xblock,
category: 'split_test',
},
xblock_info: {
...courseSectionVerticalMock.xblock_info,
category: 'split_test',
},
});
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.reply(200, {
...courseUnitIndexMock,
category: 'split_test',
ancestor_info: {
...courseUnitIndexMock.ancestor_info,
child_info: {
...courseUnitIndexMock.ancestor_info.child_info,
category: 'split_test',
},
},
});
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
});
it('navigates to split test content page on receive window event', () => {
render(<RootWrapper />);
simulatePostMessageEvent(messageTypes.handleViewXBlockContent, { usageId: newUnitId });
expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseId}/container/${newUnitId}/${sequenceId}`);
});
it('navigates to group configuration page on receive window event', () => {
const groupId = 12345;
render(<RootWrapper />);
simulatePostMessageEvent(messageTypes.handleViewGroupConfigurations, { usageId: `${courseId}#${groupId}` });
expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseId}/group_configurations#${groupId}`);
});
it('displays processing notification on receiving post message', async () => {
const { getByText, queryByText } = render(<RootWrapper />);
await waitFor(() => {
simulatePostMessageEvent(messageTypes.addNewComponent);
expect(getByText(('Adding'))).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.hideProcessingNotification);
expect(queryByText(('Adding'))).not.toBeInTheDocument();
simulatePostMessageEvent(messageTypes.pasteNewComponent);
expect(getByText(('Pasting'))).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.hideProcessingNotification);
expect(queryByText(('Pasting'))).not.toBeInTheDocument();
});
});
it('should render split test content page correctly', async () => {
const {
getByText,
getByRole,
queryByRole,
getByTestId,
queryByText,
} = render(<RootWrapper />);
const currentSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name;
const currentSubSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name;
const helpLinkUrl = 'https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_components.html#components-that-contain-other-components';
await waitFor(() => {
const unitHeaderTitle = getByTestId('unit-header-title');
expect(getByText(unitDisplayName)).toBeInTheDocument();
expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage })).toBeInTheDocument();
expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonSettings.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: currentSectionName })).toBeInTheDocument();
expect(getByRole('button', { name: currentSubSectionName })).toBeInTheDocument();
expect(queryByRole('heading', { name: addComponentMessages.title.defaultMessage })).not.toBeInTheDocument();
expect(queryByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage })).not.toBeInTheDocument();
expect(queryByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage })).not.toBeInTheDocument();
expect(queryByRole('heading', { name: /unit tags/i })).not.toBeInTheDocument();
expect(queryByRole('heading', { name: /unit location/i })).not.toBeInTheDocument();
// Sidebar
const sidebarContent = [
{ query: queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestAddComponentTitle.defaultMessage },
{ query: queryByText, name: sidebarMessages.sidebarSplitTestSelectComponentType.defaultMessage.replaceAll('{bold_tag}', '') },
{ query: queryByText, name: sidebarMessages.sidebarSplitTestComponentAdded.defaultMessage },
{ query: queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestEditComponentTitle.defaultMessage },
{ query: queryByText, name: sidebarMessages.sidebarSplitTestEditComponentInstruction.defaultMessage.replaceAll('{bold_tag}', '') },
{ query: queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestReorganizeComponentTitle.defaultMessage },
{ query: queryByText, name: sidebarMessages.sidebarSplitTestReorganizeComponentInstruction.defaultMessage },
{ query: queryByText, name: sidebarMessages.sidebarSplitTestReorganizeGroupsInstruction.defaultMessage },
{ query: queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestExperimentComponentTitle.defaultMessage },
{ query: queryByText, name: sidebarMessages.sidebarSplitTestExperimentComponentInstruction.defaultMessage },
{ query: queryByRole, type: 'link', name: sidebarMessages.sidebarSplitTestLearnMoreLinkLabel.defaultMessage },
];
sidebarContent.forEach(({ query, type, name }) => {
expect(type ? query(type, { name }) : query(name)).toBeInTheDocument();
});
expect(
queryByRole('link', { name: sidebarMessages.sidebarSplitTestLearnMoreLinkLabel.defaultMessage }),
).toHaveAttribute('href', helpLinkUrl);
});
});
it('should display visibility modal correctly', async () => (
checkRenderVisibilityModal('splitTestAccess')
));
it('opens legacy edit modal on edit button click', checkLegacyEditModalOnEditMessage);
});
it('renders and navigates to the new HTML XBlock editor after xblock duplicating', async () => {
const { getByTitle } = render(<RootWrapper />);
const updatedCourseVerticalChildrenMock = JSON.parse(JSON.stringify(courseVerticalChildrenMock));
const targetBlockId = updatedCourseVerticalChildrenMock.children[1].block_id;
updatedCourseVerticalChildrenMock.children = updatedCourseVerticalChildrenMock.children
.map((child) => (child.block_id === targetBlockId
? { ...child, block_type: 'html' }
: child));
axiosMock
.onGet(getCourseVerticalChildrenApiUrl(blockId))
.reply(200, updatedCourseVerticalChildrenMock);
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.currentXBlockId, {
id: targetBlockId,
});
});
waitFor(() => {
simulatePostMessageEvent(messageTypes.duplicateXBlock, {});
simulatePostMessageEvent(messageTypes.newXBlockEditor, {});
expect(mockedUsedNavigate)
.toHaveBeenCalledWith(`/course/${courseId}/editor/html/${targetBlockId}`, { replace: true });
});
});
});

View File

@@ -17,23 +17,35 @@ import { messageTypes } from '../constants';
import { useIframe } from '../context/hooks';
import { useEventListener } from '../../generic/hooks';
const AddComponent = ({ blockId, handleCreateNewCourseXBlock }) => {
const AddComponent = ({
parentLocator,
isSplitTestType,
isUnitVerticalType,
addComponentTemplateData,
handleCreateNewCourseXBlock,
}) => {
const navigate = useNavigate();
const intl = useIntl();
const [isOpenAdvanced, openAdvanced, closeAdvanced] = useToggle(false);
const [isOpenHtml, openHtml, closeHtml] = useToggle(false);
const [isOpenOpenAssessment, openOpenAssessment, closeOpenAssessment] = useToggle(false);
const { componentTemplates = {} } = useSelector(getCourseSectionVertical);
const blockId = addComponentTemplateData.parentLocator || parentLocator;
const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle();
const [isSelectLibraryContentModalOpen, showSelectLibraryContentModal, closeSelectLibraryContentModal] = useToggle();
const [selectedComponents, setSelectedComponents] = useState([]);
const [usageId, setUsageId] = useState(null);
const { sendMessageToIframe } = useIframe();
const receiveMessage = useCallback(({ data: { type } }) => {
const receiveMessage = useCallback(({ data: { type, payload } }) => {
if (type === messageTypes.showMultipleComponentPicker) {
showSelectLibraryContentModal();
}
}, [showSelectLibraryContentModal]);
if (type === messageTypes.showSingleComponentPicker) {
setUsageId(payload.usageId);
showAddLibraryContentModal();
}
}, [showSelectLibraryContentModal, showAddLibraryContentModal, setUsageId]);
useEventListener('message', receiveMessage);
@@ -46,11 +58,11 @@ const AddComponent = ({ blockId, handleCreateNewCourseXBlock }) => {
handleCreateNewCourseXBlock({
type: COMPONENT_TYPES.libraryV2,
category: selection.blockType,
parentLocator: blockId,
parentLocator: usageId || blockId,
libraryContentKey: selection.usageKey,
});
closeAddLibraryContentModal();
}, []);
}, [usageId]);
const handleCreateNewXBlock = (type, moduleName) => {
switch (type) {
@@ -77,14 +89,10 @@ const AddComponent = ({ blockId, handleCreateNewCourseXBlock }) => {
showAddLibraryContentModal();
break;
case COMPONENT_TYPES.advanced:
handleCreateNewCourseXBlock({
type: moduleName, category: moduleName, parentLocator: blockId,
});
handleCreateNewCourseXBlock({ type: moduleName, category: moduleName, parentLocator: blockId });
break;
case COMPONENT_TYPES.openassessment:
handleCreateNewCourseXBlock({
boilerplate: moduleName, category: type, parentLocator: blockId,
});
handleCreateNewCourseXBlock({ boilerplate: moduleName, category: type, parentLocator: blockId });
break;
case COMPONENT_TYPES.html:
handleCreateNewCourseXBlock({
@@ -100,104 +108,135 @@ const AddComponent = ({ blockId, handleCreateNewCourseXBlock }) => {
}
};
if (!Object.keys(componentTemplates).length) {
return null;
if (isUnitVerticalType || isSplitTestType) {
return (
<div className="py-4">
{Object.keys(componentTemplates).length && isUnitVerticalType ? (
<>
<h5 className="h3 mb-4 text-center">{intl.formatMessage(messages.title)}</h5>
<ul className="new-component-type list-unstyled m-0 d-flex flex-wrap justify-content-center">
{componentTemplates.map((component) => {
const { type, displayName, beta } = component;
let modalParams;
if (!component.templates.length) {
return null;
}
switch (type) {
case COMPONENT_TYPES.advanced:
modalParams = {
open: openAdvanced,
close: closeAdvanced,
isOpen: isOpenAdvanced,
};
break;
case COMPONENT_TYPES.html:
modalParams = {
open: openHtml,
close: closeHtml,
isOpen: isOpenHtml,
};
break;
case COMPONENT_TYPES.openassessment:
modalParams = {
open: openOpenAssessment,
close: closeOpenAssessment,
isOpen: isOpenOpenAssessment,
};
break;
default:
return (
<li key={type}>
<AddComponentButton
onClick={() => handleCreateNewXBlock(type)}
displayName={displayName}
type={type}
beta={beta}
/>
</li>
);
}
return (
<ComponentModalView
key={type}
component={component}
handleCreateNewXBlock={handleCreateNewXBlock}
modalParams={modalParams}
/>
);
})}
</ul>
</>
) : null}
<StandardModal
title={
isAddLibraryContentModalOpen
? intl.formatMessage(messages.singleComponentPickerModalTitle)
: intl.formatMessage(messages.multipleComponentPickerModalTitle)
}
isOpen={isAddLibraryContentModalOpen || isSelectLibraryContentModalOpen}
onClose={() => {
closeAddLibraryContentModal();
closeSelectLibraryContentModal();
}}
isOverflowVisible={false}
size="xl"
footerNode={
isSelectLibraryContentModalOpen && (
<ActionRow>
<Button onClick={onComponentSelectionSubmit}>
<FormattedMessage {...messages.multipleComponentPickerModalBtn} />
</Button>
</ActionRow>
)
}
>
<ComponentPicker
showOnlyPublished
componentPickerMode={isAddLibraryContentModalOpen ? 'single' : 'multiple'}
onComponentSelected={handleLibraryV2Selection}
onChangeComponentSelection={setSelectedComponents}
/>
</StandardModal>
</div>
);
}
return (
<div className="py-4">
<h5 className="h3 mb-4 text-center">{intl.formatMessage(messages.title)}</h5>
<ul className="new-component-type list-unstyled m-0 d-flex flex-wrap justify-content-center">
{componentTemplates.map((component) => {
const { type, displayName, beta } = component;
let modalParams;
return null;
};
if (!component.templates.length) {
return null;
}
switch (type) {
case COMPONENT_TYPES.advanced:
modalParams = {
open: openAdvanced,
close: closeAdvanced,
isOpen: isOpenAdvanced,
};
break;
case COMPONENT_TYPES.html:
modalParams = {
open: openHtml,
close: closeHtml,
isOpen: isOpenHtml,
};
break;
case COMPONENT_TYPES.openassessment:
modalParams = {
open: openOpenAssessment,
close: closeOpenAssessment,
isOpen: isOpenOpenAssessment,
};
break;
default:
return (
<li key={type}>
<AddComponentButton
onClick={() => handleCreateNewXBlock(type)}
displayName={displayName}
type={type}
beta={beta}
/>
</li>
);
}
return (
<ComponentModalView
key={type}
component={component}
handleCreateNewXBlock={handleCreateNewXBlock}
modalParams={modalParams}
/>
);
})}
</ul>
<StandardModal
title={
isAddLibraryContentModalOpen
? intl.formatMessage(messages.singleComponentPickerModalTitle)
: intl.formatMessage(messages.multipleComponentPickerModalTitle)
}
isOpen={isAddLibraryContentModalOpen || isSelectLibraryContentModalOpen}
onClose={() => {
closeAddLibraryContentModal();
closeSelectLibraryContentModal();
}}
isOverflowVisible={false}
size="xl"
footerNode={
isSelectLibraryContentModalOpen && (
<ActionRow>
<Button variant="primary" onClick={onComponentSelectionSubmit}>
<FormattedMessage {...messages.multipleComponentPickerModalBtn} />
</Button>
</ActionRow>
)
}
>
<ComponentPicker
showOnlyPublished
componentPickerMode={isAddLibraryContentModalOpen ? 'single' : 'multiple'}
onComponentSelected={handleLibraryV2Selection}
onChangeComponentSelection={setSelectedComponents}
/>
</StandardModal>
</div>
);
AddComponent.defaultProps = {
addComponentTemplateData: {},
};
AddComponent.propTypes = {
blockId: PropTypes.string.isRequired,
isSplitTestType: PropTypes.bool.isRequired,
isUnitVerticalType: PropTypes.bool.isRequired,
parentLocator: PropTypes.string.isRequired,
handleCreateNewCourseXBlock: PropTypes.func.isRequired,
addComponentTemplateData: {
blockId: PropTypes.string.isRequired,
model: 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,
}),
}),
},
};
export default AddComponent;

View File

@@ -64,6 +64,9 @@ const renderComponent = (props) => render(
<IframeProvider>
<AddComponent
blockId={blockId}
isUnitVerticalType
parentLocator={blockId}
addComponentTemplateData={{}}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlockMock}
{...props}
/>

View File

@@ -14,6 +14,7 @@ const ComponentModalView = ({
component,
modalParams,
handleCreateNewXBlock,
isRequestedModalView,
}) => {
const intl = useIntl();
const dispatch = useDispatch();
@@ -30,15 +31,19 @@ const ComponentModalView = ({
setModuleTitle('');
};
const renderAddComponentButton = () => (
<li>
<AddComponentButton
onClick={open}
type={type}
displayName={displayName}
/>
</li>
);
return (
<>
<li>
<AddComponentButton
onClick={open}
type={type}
displayName={displayName}
/>
</li>
{!isRequestedModalView && renderAddComponentButton()}
<ModalContainer
isOpen={isOpen}
close={close}
@@ -92,6 +97,10 @@ const ComponentModalView = ({
);
};
ComponentModalView.defaultProps = {
isRequestedModalView: false,
};
ComponentModalView.propTypes = {
modalParams: PropTypes.shape({
open: PropTypes.func,
@@ -117,6 +126,7 @@ ComponentModalView.propTypes = {
showLegend: PropTypes.bool,
}),
}).isRequired,
isRequestedModalView: PropTypes.bool,
};
export default ComponentModalView;

View File

@@ -3,7 +3,7 @@
background: transparent;
}
.sub-header-title .sub-header-breadcrumbs {
.sub-header-breadcrumbs {
.dropdown-toggle::after {
display: none;
}

View File

@@ -26,43 +26,55 @@ const Breadcrumbs = ({ courseId, parentUnitId }: { courseId: string, parentUnitI
isOutlinePage ? getPathToCourseOutlinePage(url) : getPathToCourseUnitPage(url)
);
const hasChildWithUrl = (children = []) => (
!!children.filter((child : any) => child?.url).length
);
return (
<nav className="d-flex align-center mb-2.5">
<ol className="p-0 m-0 d-flex align-center">
<ol className="p-0 m-0 d-flex align-center flex-wrap">
{ancestorXblocks.map(({ children, title, isLast }, index) => (
<li
className="d-flex"
className="d-flex mb-2.5"
// eslint-disable-next-line react/no-array-index-key
key={`${title}-${index}`}
>
<Dropdown>
<Dropdown.Toggle
id="breadcrumbs-dropdown-section"
variant="link"
className="p-0 text-primary small"
>
{hasChildWithUrl(children) ? (
<Dropdown>
<Dropdown.Toggle
id="breadcrumbs-dropdown-section"
variant="link"
className="p-0 text-primary small"
>
<span className="small text-gray-700">
{title}
</span>
<Icon
src={ArrowDropDownIcon}
className="text-primary ml-1"
/>
</Dropdown.Toggle>
<Dropdown.Menu>
{children.map(({ url, displayName }) => (
<Dropdown.Item
as={Link}
key={url}
to={getPathToCoursePage(index < 2, url)}
className="small"
data-testid={`breadcrumbs-dropdown-item-level-${index}`}
>
{displayName}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
) : (
<span className="p-0 text-primary small btn btn-link text-decoration-none">
<span className="small text-gray-700">
{title}
</span>
<Icon
src={ArrowDropDownIcon}
className="text-primary ml-1"
/>
</Dropdown.Toggle>
<Dropdown.Menu>
{children.map(({ url, displayName }) => (
<Dropdown.Item
as={Link}
key={url}
to={getPathToCoursePage(index < 2, url)}
className="small"
data-testid={`breadcrumbs-dropdown-item-level-${index}`}
>
{displayName}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
</span>
)}
{!isLast && (
<Icon
src={ChevronRightIcon}

View File

@@ -55,6 +55,7 @@ export const messageTypes = {
completeXBlockMoving: 'completeXBlockMoving',
rollbackMovedXBlock: 'rollbackMovedXBlock',
showMultipleComponentPicker: 'showMultipleComponentPicker',
showSingleComponentPicker: 'showSingleComponentPicker',
addSelectedComponentsToBank: 'addSelectedComponentsToBank',
showXBlockLibraryChangesPreview: 'showXBlockLibraryChangesPreview',
copyXBlock: 'copyXBlock',
@@ -69,6 +70,7 @@ export const messageTypes = {
addXBlock: 'addXBlock',
scrollToXBlock: 'scrollToXBlock',
handleViewXBlockContent: 'handleViewXBlockContent',
handleViewGroupConfigurations: 'handleViewGroupConfigurations',
editXBlock: 'editXBlock',
closeXBlockEditorModal: 'closeXBlockEditorModal',
saveEditedXBlockData: 'saveEditedXBlockData',
@@ -76,4 +78,10 @@ export const messageTypes = {
studioAjaxError: 'studioAjaxError',
refreshPositions: 'refreshPositions',
openManageTags: 'openManageTags',
showComponentTemplates: 'showComponentTemplates',
addNewComponent: 'addNewComponent',
pasteNewComponent: 'pasteComponent',
copyXBlockLegacy: 'copyXBlockLegacy',
hideProcessingNotification: 'hideProcessingNotification',
handleRedirectToXBlockEditPage: 'handleRedirectToXBlockEditPage',
};

View File

@@ -5,7 +5,7 @@ import { logError } from '@edx/frontend-platform/logging';
export interface IframeContextType {
setIframeRef: (ref: MutableRefObject<HTMLIFrameElement | null>) => void;
sendMessageToIframe: (messageType: string, payload: unknown) => void;
sendMessageToIframe: (messageType: string, payload: unknown, consumerWindow?: Window | null) => void;
}
export const IframeContext = createContext<IframeContextType | undefined>(undefined);
@@ -16,11 +16,12 @@ export const IframeProvider: React.FC = ({ children }: { children: ReactNode })
iframeRef.current = ref.current;
}, []);
const sendMessageToIframe = useCallback((messageType: string, payload: any) => {
const sendMessageToIframe = useCallback((messageType: string, payload: any, consumerWindow?: Window | null) => {
const iframeWindow = iframeRef?.current?.contentWindow;
if (iframeWindow) {
const targetWindow = consumerWindow || iframeWindow;
if (targetWindow) {
try {
iframeWindow.postMessage({ type: messageType, payload }, '*');
targetWindow.postMessage({ type: messageType, payload }, '*');
} catch (error) {
logError('Failed to send message to iframe:', error);
}

View File

@@ -30,7 +30,6 @@ import {
fetchSequenceSuccess,
fetchCourseSectionVerticalDataSuccess,
updateLoadingCourseSectionVerticalDataStatus,
updateLoadingCourseXblockStatus,
updateCourseVerticalChildren,
updateCourseVerticalChildrenLoadingStatus,
updateQueryPendingStatus,
@@ -200,18 +199,30 @@ export function createNewCourseXBlock(body, callback, blockId, sendMessageToIfra
});
} catch (error) {
dispatch(hideProcessingNotification());
dispatch(updateLoadingCourseXblockStatus({ status: RequestStatus.FAILED }));
handleResponseErrors(error, dispatch, updateSavingStatus);
}
};
}
export function fetchCourseVerticalChildrenData(itemId) {
export function fetchCourseVerticalChildrenData(itemId, isSplitTestType, skipPageLoading) {
return async (dispatch) => {
dispatch(updateCourseVerticalChildrenLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
if (!skipPageLoading) {
dispatch(updateCourseVerticalChildrenLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
}
try {
const courseVerticalChildrenData = await getCourseVerticalChildren(itemId);
if (isSplitTestType) {
const blockIds = courseVerticalChildrenData.children.map(child => child.blockId);
const childrenDataArray = await Promise.all(
blockIds.map(blockId => getCourseVerticalChildren(blockId)),
);
const allChildren = childrenDataArray.reduce(
(acc, data) => acc.concat(data.children || []),
[],
);
courseVerticalChildrenData.children = [...courseVerticalChildrenData.children, ...allChildren];
}
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
dispatch(updateCourseVerticalChildrenLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
@@ -300,13 +311,13 @@ export function patchUnitItemQuery({
dispatch(updateMovedXBlockParams(xBlockParams));
dispatch(updateCourseOutlineInfo({}));
dispatch(updateCourseOutlineInfoLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
callbackFn(sourceLocator);
try {
const courseUnit = await getCourseUnitData(currentParentLocator);
dispatch(fetchCourseItemSuccess(courseUnit));
} catch (error) {
handleResponseErrors(error, dispatch, updateSavingStatus);
}
callbackFn(sourceLocator);
} catch (error) {
handleResponseErrors(error, dispatch, updateSavingStatus);
} finally {

View File

@@ -6,13 +6,13 @@ import { Edit as EditIcon } from '@openedx/paragon/icons';
import { COURSE_BLOCK_NAMES } from '../../constants';
import messages from './messages';
const HeaderNavigations = ({ headerNavigationsActions, unitCategory }) => {
const HeaderNavigations = ({ headerNavigationsActions, category }) => {
const intl = useIntl();
const { handleViewLive, handlePreview, handleEdit } = headerNavigationsActions;
return (
<nav className="header-navigations ml-auto flex-shrink-0">
{unitCategory === COURSE_BLOCK_NAMES.vertical.id && (
{category === COURSE_BLOCK_NAMES.vertical.id && (
<>
<Button
variant="outline-primary"
@@ -28,11 +28,12 @@ const HeaderNavigations = ({ headerNavigationsActions, unitCategory }) => {
</Button>
</>
)}
{unitCategory === COURSE_BLOCK_NAMES.libraryContent.id && (
{[COURSE_BLOCK_NAMES.libraryContent.id, COURSE_BLOCK_NAMES.splitTest.id].includes(category) && (
<Button
iconBefore={EditIcon}
variant="outline-primary"
onClick={handleEdit}
data-testid="header-edit-button"
>
{intl.formatMessage(messages.editButton)}
</Button>
@@ -47,7 +48,7 @@ HeaderNavigations.propTypes = {
handlePreview: PropTypes.func.isRequired,
handleEdit: PropTypes.func.isRequired,
}).isRequired,
unitCategory: PropTypes.string.isRequired,
category: PropTypes.string.isRequired,
};
export default HeaderNavigations;

View File

@@ -18,6 +18,7 @@ const headerNavigationsActions = {
const renderComponent = (props) => render(
<IntlProvider locale="en">
<HeaderNavigations
category={COURSE_BLOCK_NAMES.vertical.id}
headerNavigationsActions={headerNavigationsActions}
{...props}
/>
@@ -47,17 +48,17 @@ describe('<HeaderNavigations />', () => {
expect(editButton).not.toBeInTheDocument();
});
it('calls the correct handlers when clicking buttons for library page', () => {
const { getByRole, queryByRole } = renderComponent({ unitCategory: COURSE_BLOCK_NAMES.libraryContent.id });
['libraryContent', 'splitTest'].forEach((category) => {
it(`calls the correct handlers when clicking buttons for ${category} page`, () => {
const { getByRole, queryByRole } = renderComponent({ category: COURSE_BLOCK_NAMES[category].id });
const editButton = getByRole('button', { name: messages.editButton.defaultMessage });
fireEvent.click(editButton);
expect(handleViewLiveFn).toHaveBeenCalledTimes(1);
const editButton = getByRole('button', { name: messages.editButton.defaultMessage });
fireEvent.click(editButton);
expect(handleViewLiveFn).toHaveBeenCalledTimes(1);
const viewLiveButton = queryByRole('button', { name: messages.viewLiveButton.defaultMessage });
expect(viewLiveButton).not.toBeInTheDocument();
const previewButton = queryByRole('button', { name: messages.previewButton.defaultMessage });
expect(previewButton).not.toBeInTheDocument();
[messages.viewLiveButton.defaultMessage, messages.previewButton.defaultMessage].forEach((btnName) => {
expect(queryByRole('button', { name: btnName })).not.toBeInTheDocument();
});
});
});
});

View File

@@ -26,7 +26,13 @@ const HeaderTitle = ({
const [titleValue, setTitleValue] = useState(unitTitle);
const currentItemData = useSelector(getCourseUnitData);
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
const { selectedPartitionIndex, selectedGroupsLabel } = currentItemData.userPartitionInfo;
const { selectedPartitionIndex, selectedGroupsLabel } = currentItemData.userPartitionInfo ?? {};
const isXBlockComponent = [
COURSE_BLOCK_NAMES.libraryContent.id,
COURSE_BLOCK_NAMES.splitTest.id,
COURSE_BLOCK_NAMES.component.id,
].includes(currentItemData.category);
const onConfigureSubmit = (...arg) => {
handleConfigureSubmit(currentItemData.id, ...arg, closeConfigureModal);
@@ -87,9 +93,8 @@ const HeaderTitle = ({
onConfigureSubmit={onConfigureSubmit}
currentItemData={currentItemData}
isSelfPaced={false}
isXBlockComponent={
[COURSE_BLOCK_NAMES.libraryContent.id, COURSE_BLOCK_NAMES.component.id].includes(currentItemData.category)
}
isXBlockComponent={isXBlockComponent}
userPartitionInfo={currentItemData?.userPartitionInfo || {}}
/>
</div>
{getVisibilityMessage()}

View File

@@ -4,6 +4,7 @@ import {
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useToggle } from '@openedx/paragon';
import { camelCaseObject } from '@edx/frontend-platform/utils';
import { RequestStatus } from '../data/constants';
import { useClipboard } from '../generic/clipboard';
@@ -46,6 +47,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
const dispatch = useDispatch();
const [searchParams] = useSearchParams();
const { sendMessageToIframe } = useIframe();
const [addComponentTemplateData, setAddComponentTemplateData] = useState({});
const [isMoveModalOpen, openMoveModal, closeMoveModal] = useToggle(false);
const courseUnit = useSelector(getCourseUnitData);
@@ -68,6 +70,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
const sequenceId = courseUnit.ancestorInfo?.ancestors[0].id;
const isUnitVerticalType = unitCategory === COURSE_BLOCK_NAMES.vertical.id;
const isUnitLibraryType = unitCategory === COURSE_BLOCK_NAMES.libraryContent.id;
const isSplitTestType = unitCategory === COURSE_BLOCK_NAMES.splitTest.id;
const headerNavigationsActions = {
handleViewLive: () => {
@@ -76,7 +79,9 @@ export const useCourseUnit = ({ courseId, blockId }) => {
handlePreview: () => {
window.open(draftPreviewLink, '_blank');
},
handleEdit: () => {},
handleEdit: () => {
sendMessageToIframe(messageTypes.editXBlock, { id: courseUnit.id }, window);
},
};
const handleTitleEdit = () => {
@@ -170,6 +175,16 @@ export const useCourseUnit = ({ courseId, blockId }) => {
const { usageId } = payload;
navigate(`/course/${courseId}/container/${usageId}/${sequenceId}`);
}
if (type === messageTypes.handleViewGroupConfigurations) {
const { usageId } = payload;
const groupId = usageId.split('#').pop();
navigate(`/course/${courseId}/group_configurations#${groupId}`);
}
if (type === messageTypes.showComponentTemplates) {
setAddComponentTemplateData(camelCaseObject(payload));
}
}, [courseId, sequenceId]);
useEventListener('message', receiveMessage);
@@ -183,12 +198,17 @@ export const useCourseUnit = ({ courseId, blockId }) => {
useEffect(() => {
dispatch(fetchCourseUnitQuery(blockId));
dispatch(fetchCourseSectionVerticalData(blockId, sequenceId));
dispatch(fetchCourseVerticalChildrenData(blockId));
dispatch(fetchCourseVerticalChildrenData(blockId, isSplitTestType));
handleNavigate(sequenceId);
dispatch(updateMovedXBlockParams({ isSuccess: false }));
}, [courseId, blockId, sequenceId]);
useEffect(() => {
if (isSplitTestType) {
dispatch(fetchCourseVerticalChildrenData(blockId, isSplitTestType));
}
}, [isSplitTestType, blockId]);
useEffect(() => {
if (isMoveModalOpen && !Object.keys(courseOutlineInfo).length) {
dispatch(getCourseOutlineInfoQuery(courseId));
@@ -209,6 +229,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
isTitleEditFormOpen,
isUnitVerticalType,
isUnitLibraryType,
isSplitTestType,
sharedClipboardData,
showPasteXBlock,
showPasteUnit,
@@ -227,6 +248,8 @@ export const useCourseUnit = ({ courseId, blockId }) => {
handleCloseXBlockMovedAlert,
movedXBlockParams,
handleNavigateToTargetUnit,
addComponentTemplateData,
setAddComponentTemplateData,
};
};

View File

@@ -81,4 +81,19 @@
text-decoration: line-through;
}
}
.course-split-test-sidebar {
padding: $spacer;
@extend %base-font-params;
.course-split-test-sidebar-title {
font-size: $font-size-base;
line-height: $line-height-base;
}
.course-split-test-sidebar-devider {
width: 100%;
}
}
}

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { Card, Hyperlink, Stack } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
const SplitTestSidebarInfo = () => {
const intl = useIntl();
const boldTagWrapper = (chunks: React.ReactNode) => <strong>{chunks}</strong>;
return (
<Card.Body className="course-split-test-sidebar">
<Stack>
<h3 className="course-split-test-sidebar-title">
{intl.formatMessage(messages.sidebarSplitTestAddComponentTitle)}
</h3>
<p>
{intl.formatMessage(messages.sidebarSplitTestSelectComponentType, { bold_tag: boldTagWrapper })}
</p>
<p>
{intl.formatMessage(messages.sidebarSplitTestComponentAdded)}
</p>
<h3 className="course-split-test-sidebar-title">
{intl.formatMessage(messages.sidebarSplitTestEditComponentTitle)}
</h3>
<p>
{intl.formatMessage(messages.sidebarSplitTestEditComponentInstruction, { bold_tag: boldTagWrapper })}
</p>
<h3 className="course-split-test-sidebar-title">
{intl.formatMessage(messages.sidebarSplitTestReorganizeComponentTitle)}
</h3>
<p>
{intl.formatMessage(messages.sidebarSplitTestReorganizeComponentInstruction)}
</p>
<p>
{intl.formatMessage(messages.sidebarSplitTestReorganizeGroupsInstruction)}
</p>
<h3 className="course-split-test-sidebar-title">
{intl.formatMessage(messages.sidebarSplitTestExperimentComponentTitle)}
</h3>
<p className="mb-0">
{intl.formatMessage(messages.sidebarSplitTestExperimentComponentInstruction)}
</p>
<hr className="course-split-test-sidebar-devider my-4" />
<Hyperlink
showLaunchIcon={false}
destination="https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_components.html#components-that-contain-other-components"
className="btn btn-outline-primary btn-sm"
target="_blank"
>
{intl.formatMessage(messages.sidebarSplitTestLearnMoreLinkLabel)}
</Hyperlink>
</Stack>
</Card.Body>
);
};
export default SplitTestSidebarInfo;

View File

@@ -137,6 +137,61 @@ const messages = defineMessages({
id: 'course-authoring.course-unit.modal.make-visibility.description',
defaultMessage: 'If the unit was previously published and released to students, any changes you made to the unit when it was hidden will now be visible to students. Do you want to proceed?',
},
sidebarSplitTestAddComponentTitle: {
id: 'course-authoring.course-unit.split-test.sidebar.add-component.title',
defaultMessage: 'Adding components',
description: 'Title for the section that explains how to add components to a split test',
},
sidebarSplitTestSelectComponentType: {
id: 'course-authoring.course-unit.split-test.sidebar.add-component.select-type',
defaultMessage: 'Select a component type under {bold_tag}Add New Component{bold_tag}. Then select a template.',
description: 'Instruction text for selecting a component type and template when adding new components',
},
sidebarSplitTestComponentAdded: {
id: 'course-authoring.course-unit.split-test.sidebar.add-component.component-added',
defaultMessage: 'The new component is added at the bottom of the page or group. You can then edit and move the component.',
description: 'Instruction text indicating that the component has been added and can be moved or edited',
},
sidebarSplitTestEditComponentTitle: {
id: 'course-authoring.course-unit.split-test.sidebar.edit-component.title',
defaultMessage: 'Editing components',
description: 'Title for the section that explains how to edit components in a split test',
},
sidebarSplitTestEditComponentInstruction: {
id: 'course-authoring.course-unit.split-test.sidebar.edit-component.instruction',
defaultMessage: 'Click the {bold_tag}Edit{bold_tag} icon in a component to edit its content.',
description: 'Instruction text for editing a component by clicking the edit icon',
},
sidebarSplitTestReorganizeComponentTitle: {
id: 'course-authoring.course-unit.split-test.sidebar.reorganize-component.title',
defaultMessage: 'Reorganizing components',
description: 'Title for the section that explains how to reorganize components within a split test',
},
sidebarSplitTestReorganizeComponentInstruction: {
id: 'course-authoring.course-unit.split-test.sidebar.reorganize-component.instruction',
defaultMessage: 'Drag components to new locations within this component.',
description: 'Instruction text for reorganizing components by dragging them to new locations within a split test',
},
sidebarSplitTestReorganizeGroupsInstruction: {
id: 'course-authoring.course-unit.split-test.sidebar.reorganize-component.drag-to-groups',
defaultMessage: 'For content experiments, you can drag components to other groups.',
description: 'Instruction text for dragging components to other groups for content experiments',
},
sidebarSplitTestExperimentComponentTitle: {
id: 'course-authoring.course-unit.split-test.sidebar.experiment-component.title',
defaultMessage: 'Working with content experiments',
description: 'Title for the section that explains how to work with content experiments',
},
sidebarSplitTestExperimentComponentInstruction: {
id: 'course-authoring.course-unit.split-test.sidebar.experiment-component.confirm-config',
defaultMessage: 'Confirm that you have properly configured content in each of your experiment groups.',
description: 'Instruction text reminding users to check content configuration in each experiment group',
},
sidebarSplitTestLearnMoreLinkLabel: {
id: 'course-authoring.course-unit.split-test.sidebar.learn-more-link.label',
defaultMessage: 'Learn more about component containers',
description: 'Text for a link that directs users to more information about component containers in the split test setup.',
},
});
export default messages;

View File

@@ -12,6 +12,9 @@ export type UseMessageHandlersTypes = {
handleSaveEditedXBlockData: () => void;
handleFinishXBlockDragging: () => void;
handleOpenManageTagsModal: (id: string) => void;
handleShowProcessingNotification: (variant: string) => void;
handleHideProcessingNotification: () => void;
handleRedirectToXBlockEditPage: (payload: { type: string, locator: string }) => void;
};
export type MessageHandlersTypes = Record<string, (payload: any) => void>;

View File

@@ -2,7 +2,8 @@ import { useMemo } from 'react';
import { debounce } from 'lodash';
import { useClipboard } from '../../../generic/clipboard';
import { handleResponseErrors } from '../../../generic/saving-error-alert/utils';
import { handleResponseErrors } from '../../../generic/saving-error-alert';
import { NOTIFICATION_MESSAGES } from '../../../constants';
import { updateSavingStatus } from '../../data/slice';
import { messageTypes } from '../../constants';
import { MessageHandlersTypes, UseMessageHandlersTypes } from './types';
@@ -27,6 +28,9 @@ export const useMessageHandlers = ({
handleSaveEditedXBlockData,
handleFinishXBlockDragging,
handleOpenManageTagsModal,
handleShowProcessingNotification,
handleHideProcessingNotification,
handleRedirectToXBlockEditPage,
}: UseMessageHandlersTypes): MessageHandlersTypes => {
const { copyToClipboard } = useClipboard();
@@ -46,6 +50,11 @@ export const useMessageHandlers = ({
[messageTypes.studioAjaxError]: ({ error }) => handleResponseErrors(error, dispatch, updateSavingStatus),
[messageTypes.refreshPositions]: handleFinishXBlockDragging,
[messageTypes.openManageTags]: (payload) => handleOpenManageTagsModal(payload.contentId),
[messageTypes.addNewComponent]: () => handleShowProcessingNotification(NOTIFICATION_MESSAGES.adding),
[messageTypes.pasteNewComponent]: () => handleShowProcessingNotification(NOTIFICATION_MESSAGES.pasting),
[messageTypes.copyXBlockLegacy]: () => handleShowProcessingNotification(NOTIFICATION_MESSAGES.copying),
[messageTypes.hideProcessingNotification]: handleHideProcessingNotification,
[messageTypes.handleRedirectToXBlockEditPage]: (payload) => handleRedirectToXBlockEditPage(payload),
}), [
courseId,
handleDeleteXBlock,

View File

@@ -0,0 +1,4 @@
.xblock-container-iframe {
width: calc(100% + ($spacer * .3125));
margin: 0 (($spacer * .3125) * -1);
}

View File

@@ -6,6 +6,10 @@ import { useToggle, Sheet } from '@openedx/paragon';
import { useDispatch } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import {
hideProcessingNotification,
showProcessingNotification,
} from '../../generic/processing-notification/data/slice';
import DeleteModal from '../../generic/delete-modal/DeleteModal';
import ConfigureModal from '../../generic/configure-modal/ConfigureModal';
import ModalIframe from '../../generic/modal-iframe';
@@ -13,24 +17,27 @@ import { IFRAME_FEATURE_POLICY } from '../../constants';
import ContentTagsDrawer from '../../content-tags-drawer/ContentTagsDrawer';
import supportedEditors from '../../editors/supportedEditors';
import { useIframe } from '../context/hooks';
import { updateCourseUnitSidebar } from '../data/thunk';
import {
fetchCourseSectionVerticalData,
fetchCourseVerticalChildrenData,
updateCourseUnitSidebar,
} from '../data/thunk';
import { messageTypes } from '../constants';
import {
useMessageHandlers,
useIframeContent,
useIframeMessages,
useIFrameBehavior,
} from './hooks';
import { formatAccessManagedXBlockData, getIframeUrl, getLegacyEditModalUrl } from './utils';
import messages from './messages';
import { messageTypes } from '../constants';
import {
XBlockContainerIframeProps,
AccessManagedXBlockDataTypes,
} from './types';
import { formatAccessManagedXBlockData, getIframeUrl, getLegacyEditModalUrl } from './utils';
import messages from './messages';
const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
courseId, blockId, unitXBlockActions, courseVerticalChildren, handleConfigureSubmit,
courseId, blockId, unitXBlockActions, courseVerticalChildren, handleConfigureSubmit, isUnitVerticalType,
}) => {
const intl = useIntl();
const iframeRef = useRef<HTMLIFrameElement>(null);
@@ -116,6 +123,9 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
const handleSaveEditedXBlockData = () => {
sendMessageToIframe(messageTypes.completeXBlockEditing, { locator: configureXBlockId });
dispatch(updateCourseUnitSidebar(blockId));
if (!isUnitVerticalType) {
dispatch(fetchCourseSectionVerticalData(blockId));
}
};
const handleFinishXBlockDragging = () => {
@@ -127,6 +137,21 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
openManageTagsModal();
};
const handleShowProcessingNotification = (variant: string) => {
if (variant) {
dispatch(showProcessingNotification(variant));
}
};
const handleHideProcessingNotification = () => {
dispatch(fetchCourseVerticalChildrenData(blockId, true, true));
dispatch(hideProcessingNotification());
};
const handleRedirectToXBlockEditPage = (payload: { type: string, locator: string }) => {
navigate(`/course/${courseId}/editor/${payload.type}/${payload.locator}`);
};
const messageHandlers = useMessageHandlers({
courseId,
navigate,
@@ -141,6 +166,9 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
handleSaveEditedXBlockData,
handleFinishXBlockDragging,
handleOpenManageTagsModal,
handleShowProcessingNotification,
handleHideProcessingNotification,
handleRedirectToXBlockEditPage,
});
useIframeMessages(messageHandlers);
@@ -181,9 +209,10 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
allow={IFRAME_FEATURE_POLICY}
allowFullScreen
loading="lazy"
style={{ width: '100%', height: iframeHeight + iframeOffset }}
style={{ height: iframeHeight + iframeOffset }}
scrolling="no"
referrerPolicy="origin"
className="xblock-container-iframe"
aria-label={intl.formatMessage(messages.xblockIframeLabel, { xblockCount: courseVerticalChildren.length })}
/>
{configureXBlockId && (

View File

@@ -41,6 +41,7 @@ export interface XBlockTypes {
export interface XBlockContainerIframeProps {
courseId: string;
blockId: string;
isUnitVerticalType: boolean,
unitXBlockActions: {
handleDelete: (XBlockId: string | null) => void;
handleDuplicate: (XBlockId: string | null) => void;

View File

@@ -170,6 +170,7 @@ const ConfigureModal = ({
break;
case COURSE_BLOCK_NAMES.vertical.id:
case COURSE_BLOCK_NAMES.libraryContent.id:
case COURSE_BLOCK_NAMES.splitTest.id:
case COURSE_BLOCK_NAMES.component.id:
// groupAccess should be {partitionId: [group1, group2]} or {} if selectedPartitionIndex === -1
if (data.selectedPartitionIndex >= 0) {
@@ -248,11 +249,12 @@ const ConfigureModal = ({
);
case COURSE_BLOCK_NAMES.vertical.id:
case COURSE_BLOCK_NAMES.libraryContent.id:
case COURSE_BLOCK_NAMES.splitTest.id:
case COURSE_BLOCK_NAMES.component.id:
return (
<UnitTab
isXBlockComponent={isXBlockComponent}
isLibraryContent={COURSE_BLOCK_NAMES.libraryContent.id === category}
category={category}
values={values}
setFieldValue={setFieldValue}
showWarning={visibilityState === VisibilityTypes.STAFF_ONLY && !ancestorHasStaffLock}

View File

@@ -7,11 +7,12 @@ import {
import { Field } from 'formik';
import classNames from 'classnames';
import { COURSE_BLOCK_NAMES } from '../../constants';
import messages from './messages';
const UnitTab = ({
isXBlockComponent,
isLibraryContent,
category,
values,
setFieldValue,
showWarning,
@@ -44,6 +45,17 @@ const UnitTab = ({
return group.deleted && isGroupSelected;
};
const getAccessBlockTitle = () => {
switch (category) {
case COURSE_BLOCK_NAMES.libraryContent.id:
return messages.libraryContentAccess;
case COURSE_BLOCK_NAMES.splitTest.id:
return messages.splitTestAccess;
default:
return messages.unitAccess;
}
};
return (
<>
{!isXBlockComponent && (
@@ -63,7 +75,7 @@ const UnitTab = ({
{userPartitionInfo.selectablePartitions.length > 0 && (
<Form.Group controlId="groupSelect">
<h4 className="mt-3">
<FormattedMessage {...messages[isLibraryContent ? 'libraryContentAccess' : 'unitAccess']} />
<FormattedMessage {...getAccessBlockTitle()} />
</h4>
<hr />
<Form.Label as="legend" className="font-weight-bold">
@@ -149,12 +161,12 @@ const UnitTab = ({
UnitTab.defaultProps = {
isXBlockComponent: false,
isLibraryContent: false,
category: undefined,
};
UnitTab.propTypes = {
isXBlockComponent: PropTypes.bool,
isLibraryContent: PropTypes.bool,
category: PropTypes.string,
values: PropTypes.shape({
isVisibleToStaffOnly: PropTypes.bool.isRequired,
discussionEnabled: PropTypes.bool,

View File

@@ -47,9 +47,13 @@ const messages = defineMessages({
defaultMessage: 'Unit access',
},
libraryContentAccess: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.lib-content-access',
id: 'course-authoring.course-outline.configure-modal.visibility-tab.library-content-access',
defaultMessage: 'Library content access',
},
splitTestAccess: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.split-test-access',
defaultMessage: 'Split Test access',
},
discussionEnabledSectionTitle: {
id: 'course-authoring.course-outline.configure-modal.discussion-enabled.section-title',
defaultMessage: 'Discussion',

View File

@@ -15,12 +15,12 @@ const SubHeader = ({
withSubHeaderContent,
}) => (
<div className={`${!hideBorder && 'border-bottom border-light-400'} mb-3`}>
{breadcrumbs && (
<div className="sub-header-breadcrumbs">{breadcrumbs}</div>
)}
<header className="sub-header">
<h2 className="sub-header-title">
<small className="sub-header-title-subtitle">{subtitle}</small>
{breadcrumbs && (
<div className="sub-header-breadcrumbs">{breadcrumbs}</div>
)}
{title}
{titleActions && (
<ActionRow className="ml-auto mt-2 justify-content-start">

View File

@@ -107,7 +107,7 @@ export const createCorrectInternalRoute = (checkPath) => {
basePath = basePath.slice(0, -1);
}
if (!checkPath.startsWith(basePath)) {
if (!checkPath?.startsWith(basePath)) {
return `${basePath}${checkPath}`;
}