} A promise that resolves with the response data.
*/
-export async function handleCourseUnitVisibilityAndData(unitId, type, isVisible) {
+export async function handleCourseUnitVisibilityAndData(unitId, type, isVisible, groupAccess) {
const body = {
- publish: type,
+ publish: groupAccess ? null : type,
...(type === PUBLISH_TYPES.republish ? {
metadata: {
- visible_to_staff_only: isVisible,
+ visible_to_staff_only: isVisible ? true : null,
+ group_access: groupAccess || null,
},
} : {}),
};
diff --git a/src/course-unit/data/slice.js b/src/course-unit/data/slice.js
index 0134fcb054..436a957b2b 100644
--- a/src/course-unit/data/slice.js
+++ b/src/course-unit/data/slice.js
@@ -16,7 +16,7 @@ const slice = createSlice({
},
unit: {},
courseSectionVertical: {},
- courseVerticalChildren: [],
+ courseVerticalChildren: {},
},
reducers: {
fetchCourseItemSuccess: (state, { payload }) => {
diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js
index e18a0cc6d6..6d63531881 100644
--- a/src/course-unit/data/thunk.js
+++ b/src/course-unit/data/thunk.js
@@ -111,19 +111,19 @@ export function editCourseItemQuery(itemId, displayName, sequenceId) {
};
}
-export function editCourseUnitVisibilityAndData(itemId, type, isVisible) {
+export function editCourseUnitVisibilityAndData(itemId, type, isVisible, groupAccess, isModalView, blockId = itemId) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(updateQueryPendingStatus(true));
- const notificationMessage = getNotificationMessage(type, isVisible);
- dispatch(showProcessingNotification(notificationMessage));
+ const notification = getNotificationMessage(type, isVisible, isModalView);
+ dispatch(showProcessingNotification(notification));
try {
- await handleCourseUnitVisibilityAndData(itemId, type, isVisible).then(async (result) => {
+ await handleCourseUnitVisibilityAndData(itemId, type, isVisible, groupAccess).then(async (result) => {
if (result) {
- const courseUnit = await getCourseUnitData(itemId);
+ const courseUnit = await getCourseUnitData(blockId);
dispatch(fetchCourseItemSuccess(courseUnit));
- const courseVerticalChildrenData = await getCourseVerticalChildren(itemId);
+ const courseVerticalChildrenData = await getCourseVerticalChildren(blockId);
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
diff --git a/src/course-unit/data/utils.js b/src/course-unit/data/utils.js
index a37faaa4db..49223e1a7d 100644
--- a/src/course-unit/data/utils.js
+++ b/src/course-unit/data/utils.js
@@ -30,15 +30,18 @@ export function normalizeCourseSectionVerticalData(metadata) {
* Get the notification message based on the publishing type and visibility.
* @param {string} type - The publishing type.
* @param {boolean} isVisible - The visibility status.
+ * @param {boolean} isModalView - The modal view status.
* @returns {string} The corresponding notification message.
*/
-export const getNotificationMessage = (type, isVisible) => {
+export const getNotificationMessage = (type, isVisible, isModalView) => {
let notificationMessage;
if (type === PUBLISH_TYPES.discardChanges) {
notificationMessage = NOTIFICATION_MESSAGES.discardChanges;
} else if (type === PUBLISH_TYPES.makePublic) {
notificationMessage = NOTIFICATION_MESSAGES.publishing;
+ } else if (type === PUBLISH_TYPES.republish && isModalView) {
+ notificationMessage = NOTIFICATION_MESSAGES.saving;
} else if (type === PUBLISH_TYPES.republish && !isVisible) {
notificationMessage = NOTIFICATION_MESSAGES.makingVisibleToStudents;
} else if (type === PUBLISH_TYPES.republish && isVisible) {
diff --git a/src/course-unit/header-title/HeaderTitle.jsx b/src/course-unit/header-title/HeaderTitle.jsx
index 4fc5739225..0d29404ba6 100644
--- a/src/course-unit/header-title/HeaderTitle.jsx
+++ b/src/course-unit/header-title/HeaderTitle.jsx
@@ -1,13 +1,15 @@
import { useEffect, useState } from 'react';
-import { useDispatch } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
-import { Form, IconButton } from '@openedx/paragon';
+import { Form, IconButton, useToggle } from '@openedx/paragon';
import {
EditOutline as EditIcon,
Settings as SettingsIcon,
} from '@openedx/paragon/icons';
+import ConfigureModal from '../../generic/configure-modal/ConfigureModal';
+import { getCourseUnitData } from '../data/selectors';
import { updateQueryPendingStatus } from '../data/slice';
import messages from './messages';
@@ -16,10 +18,30 @@ const HeaderTitle = ({
isTitleEditFormOpen,
handleTitleEdit,
handleTitleEditSubmit,
+ handleConfigureSubmit,
}) => {
const intl = useIntl();
const dispatch = useDispatch();
const [titleValue, setTitleValue] = useState(unitTitle);
+ const currentItemData = useSelector(getCourseUnitData);
+ const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
+ const { selectedPartitionIndex, selectedGroupsLabel } = currentItemData.userPartitionInfo;
+
+ const onConfigureSubmit = (...arg) => {
+ handleConfigureSubmit(currentItemData.id, ...arg, closeConfigureModal);
+ };
+
+ const getVisibilityMessage = () => {
+ let message;
+
+ if (selectedPartitionIndex !== -1 && !Number.isNaN(selectedPartitionIndex) && selectedGroupsLabel) {
+ message = intl.formatMessage(messages.definedVisibilityMessage, { selectedGroupsLabel });
+ } else if (currentItemData.hasPartitionGroupComponents) {
+ message = intl.formatMessage(messages.commonVisibilityMessage);
+ }
+
+ return message ? ({message}
) : null;
+ };
useEffect(() => {
setTitleValue(unitTitle);
@@ -27,38 +49,46 @@ const HeaderTitle = ({
}, [unitTitle]);
return (
-
- {isTitleEditFormOpen ? (
-
- e && e.focus()}
- value={titleValue}
- name="displayName"
- onChange={(e) => setTitleValue(e.target.value)}
- aria-label={intl.formatMessage(messages.ariaLabelButtonEdit)}
- onBlur={() => handleTitleEditSubmit(titleValue)}
- onKeyDown={(e) => {
- if (e.key === 'Enter') {
- handleTitleEditSubmit(titleValue);
- }
- }}
- />
-
- ) : unitTitle}
-
- {
- }}
- />
-
+ <>
+
+ {isTitleEditFormOpen ? (
+
+ e && e.focus()}
+ value={titleValue}
+ name="displayName"
+ onChange={(e) => setTitleValue(e.target.value)}
+ aria-label={intl.formatMessage(messages.ariaLabelButtonEdit)}
+ onBlur={() => handleTitleEditSubmit(titleValue)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ handleTitleEditSubmit(titleValue);
+ }
+ }}
+ />
+
+ ) : unitTitle}
+
+
+
+
+ {getVisibilityMessage()}
+ >
);
};
@@ -67,6 +97,7 @@ HeaderTitle.propTypes = {
isTitleEditFormOpen: PropTypes.bool.isRequired,
handleTitleEdit: PropTypes.func.isRequired,
handleTitleEditSubmit: PropTypes.func.isRequired,
+ handleConfigureSubmit: PropTypes.func.isRequired,
};
export default HeaderTitle;
diff --git a/src/course-unit/header-title/HeaderTitle.scss b/src/course-unit/header-title/HeaderTitle.scss
new file mode 100644
index 0000000000..753c3b7184
--- /dev/null
+++ b/src/course-unit/header-title/HeaderTitle.scss
@@ -0,0 +1,4 @@
+.header-title__visibility-message {
+ font-size: $font-size-sm;
+ font-weight: $font-weight-normal;
+}
diff --git a/src/course-unit/header-title/HeaderTitle.test.jsx b/src/course-unit/header-title/HeaderTitle.test.jsx
index b5014f89c2..7e57c408e0 100644
--- a/src/course-unit/header-title/HeaderTitle.test.jsx
+++ b/src/course-unit/header-title/HeaderTitle.test.jsx
@@ -1,3 +1,5 @@
+import MockAdapter from 'axios-mock-adapter';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
@@ -5,14 +7,21 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import initializeStore from '../../store';
+import { executeThunk } from '../../utils';
+import { getCourseUnitApiUrl } from '../data/api';
+import { fetchCourseUnitQuery } from '../data/thunk';
+import { courseUnitIndexMock } from '../__mocks__';
import HeaderTitle from './HeaderTitle';
import messages from './messages';
+const blockId = '123';
const unitTitle = 'Getting Started';
const isTitleEditFormOpen = false;
const handleTitleEdit = jest.fn();
const handleTitleEditSubmit = jest.fn();
+const handleConfigureSubmit = jest.fn();
let store;
+let axiosMock;
const renderComponent = (props) => render(
@@ -22,6 +31,7 @@ const renderComponent = (props) => render(
isTitleEditFormOpen={isTitleEditFormOpen}
handleTitleEdit={handleTitleEdit}
handleTitleEditSubmit={handleTitleEditSubmit}
+ handleConfigureSubmit={handleConfigureSubmit}
{...props}
/>
@@ -40,6 +50,11 @@ describe(' ', () => {
});
store = initializeStore();
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ axiosMock
+ .onGet(getCourseUnitApiUrl(blockId))
+ .reply(200, courseUnitIndexMock);
+ await executeThunk(fetchCourseUnitQuery(blockId), store.dispatch);
});
it('render HeaderTitle component correctly', () => {
@@ -85,4 +100,36 @@ describe(' ', () => {
expect(titleField).toHaveValue(`${unitTitle} 1 2`);
expect(handleTitleEditSubmit).toHaveBeenCalledTimes(2);
});
+
+ it('displays a visibility message with the selected groups for the unit', async () => {
+ axiosMock
+ .onGet(getCourseUnitApiUrl(blockId))
+ .reply(200, {
+ ...courseUnitIndexMock,
+ user_partition_info: {
+ ...courseUnitIndexMock.user_partition_info,
+ selected_partition_index: '1',
+ selected_groups_label: 'Visibility group 1',
+ },
+ });
+ await executeThunk(fetchCourseUnitQuery(blockId), store.dispatch);
+ const { getByText } = renderComponent();
+ const visibilityMessage = messages.definedVisibilityMessage.defaultMessage
+ .replace('{selectedGroupsLabel}', 'Visibility group 1');
+
+ expect(getByText(visibilityMessage)).toBeInTheDocument();
+ });
+
+ it('displays a visibility message with the selected groups for some of xblock', async () => {
+ axiosMock
+ .onGet(getCourseUnitApiUrl(blockId))
+ .reply(200, {
+ ...courseUnitIndexMock,
+ has_partition_group_components: true,
+ });
+ await executeThunk(fetchCourseUnitQuery(blockId), store.dispatch);
+ const { getByText } = renderComponent();
+
+ expect(getByText(messages.commonVisibilityMessage.defaultMessage)).toBeInTheDocument();
+ });
});
diff --git a/src/course-unit/header-title/messages.js b/src/course-unit/header-title/messages.js
index c6ca9ef208..036e9ddef8 100644
--- a/src/course-unit/header-title/messages.js
+++ b/src/course-unit/header-title/messages.js
@@ -4,14 +4,27 @@ const messages = defineMessages({
altButtonEdit: {
id: 'course-authoring.course-unit.heading.button.edit.alt',
defaultMessage: 'Edit',
+ description: 'The unit edit button text',
},
ariaLabelButtonEdit: {
id: 'course-authoring.course-unit.heading.button.edit.aria-label',
defaultMessage: 'Edit field',
+ description: 'The unit edit button aria label',
},
altButtonSettings: {
id: 'course-authoring.course-unit.heading.button.settings.alt',
defaultMessage: 'Settings',
+ description: 'The unit settings button text',
+ },
+ definedVisibilityMessage: {
+ id: 'course-authoring.course-unit.heading.visibility.defined.message',
+ defaultMessage: 'Access to this unit is restricted to: {selectedGroupsLabel}',
+ description: 'Group visibility accessibility text for Unit',
+ },
+ commonVisibilityMessage: {
+ id: 'course-authoring.course-unit.heading.visibility.common.message',
+ defaultMessage: 'Access to some content in this unit is restricted to specific groups of learners.',
+ description: 'The label text of some content restriction in this unit',
},
});
diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx
index 3d636df772..7989f46f9d 100644
--- a/src/course-unit/hooks.jsx
+++ b/src/course-unit/hooks.jsx
@@ -11,6 +11,7 @@ import {
fetchCourseVerticalChildrenData,
deleteUnitItemQuery,
duplicateUnitItemQuery,
+ editCourseUnitVisibilityAndData,
} from './data/thunk';
import {
getCourseSectionVertical,
@@ -21,6 +22,7 @@ import {
getSequenceStatus,
} from './data/selectors';
import { changeEditTitleFormOpen, updateQueryPendingStatus } from './data/slice';
+import { PUBLISH_TYPES } from './constants';
// eslint-disable-next-line import/prefer-default-export
export const useCourseUnit = ({ courseId, blockId }) => {
@@ -60,6 +62,11 @@ export const useCourseUnit = ({ courseId, blockId }) => {
dispatch(changeEditTitleFormOpen(!isTitleEditFormOpen));
};
+ const handleConfigureSubmit = (id, isVisible, groupAccess, closeModalFn) => {
+ dispatch(editCourseUnitVisibilityAndData(id, PUBLISH_TYPES.republish, isVisible, groupAccess, true, blockId));
+ closeModalFn();
+ };
+
const handleTitleEditSubmit = (displayName) => {
if (unitTitle !== displayName) {
dispatch(editCourseItemQuery(blockId, displayName, sequenceId));
@@ -131,6 +138,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
handleTitleEdit,
handleTitleEditSubmit,
handleCreateNewCourseXBlock,
+ handleConfigureSubmit,
courseVerticalChildren,
};
};
diff --git a/src/files-and-videos/generic/FileInput.jsx b/src/files-and-videos/generic/FileInput.jsx
index 455853722f..c953994d22 100644
--- a/src/files-and-videos/generic/FileInput.jsx
+++ b/src/files-and-videos/generic/FileInput.jsx
@@ -11,7 +11,7 @@ export const useFileInput = ({
const click = () => ref.current.click();
const addFile = (e) => {
const { files } = e.target;
- setSelectedRows(files);
+ setSelectedRows([...files]);
onAddFile(Object.values(files));
setAddOpen();
e.target.value = '';
diff --git a/src/files-and-videos/generic/messages.js b/src/files-and-videos/generic/messages.js
index d6f6d03e87..1906f1ba78 100644
--- a/src/files-and-videos/generic/messages.js
+++ b/src/files-and-videos/generic/messages.js
@@ -8,7 +8,7 @@ const messages = defineMessages({
},
apiStatusToastMessage: {
id: 'course-authoring.files-and-upload.apiStatus.message',
- defaultMessage: '{actionType} {selectedRowCount} {fileType}(s)',
+ defaultMessage: '{actionType} {selectedRowCount} {selectedRowCount, plural, one {{fileType}} other {{fileType}s}}',
description: 'This message is showed in the toast when action is applied to files',
},
apiStatusAddingAction: {
diff --git a/src/course-outline/configure-modal/AdvancedTab.jsx b/src/generic/configure-modal/AdvancedTab.jsx
similarity index 100%
rename from src/course-outline/configure-modal/AdvancedTab.jsx
rename to src/generic/configure-modal/AdvancedTab.jsx
diff --git a/src/course-outline/configure-modal/BasicTab.jsx b/src/generic/configure-modal/BasicTab.jsx
similarity index 96%
rename from src/course-outline/configure-modal/BasicTab.jsx
rename to src/generic/configure-modal/BasicTab.jsx
index 173bc34939..182de34df1 100644
--- a/src/course-outline/configure-modal/BasicTab.jsx
+++ b/src/generic/configure-modal/BasicTab.jsx
@@ -1,9 +1,9 @@
-import React from 'react';
import PropTypes from 'prop-types';
import { Stack, Form } from '@openedx/paragon';
import { FormattedMessage, injectIntl, useIntl } from '@edx/frontend-platform/i18n';
+
+import { DatepickerControl, DATEPICKER_TYPES } from '../datepicker-control';
import messages from './messages';
-import { DatepickerControl, DATEPICKER_TYPES } from '../../generic/datepicker-control';
const BasicTab = ({
values,
diff --git a/src/course-outline/configure-modal/ConfigureModal.jsx b/src/generic/configure-modal/ConfigureModal.jsx
similarity index 77%
rename from src/course-outline/configure-modal/ConfigureModal.jsx
rename to src/generic/configure-modal/ConfigureModal.jsx
index 3e7556b466..a78bd386a7 100644
--- a/src/course-outline/configure-modal/ConfigureModal.jsx
+++ b/src/generic/configure-modal/ConfigureModal.jsx
@@ -11,12 +11,10 @@ import {
Tab,
Tabs,
} from '@openedx/paragon';
-import { useSelector } from 'react-redux';
import { Formik } from 'formik';
import { VisibilityTypes } from '../../data/constants';
-import { COURSE_BLOCK_NAMES } from '../constants';
-import { getCurrentItem, getProctoredExamsFlag } from '../data/selectors';
+import { COURSE_BLOCK_NAMES } from '../../constants';
import messages from './messages';
import BasicTab from './BasicTab';
import VisibilityTab from './VisibilityTab';
@@ -27,6 +25,9 @@ const ConfigureModal = ({
isOpen,
onClose,
onConfigureSubmit,
+ currentItemData,
+ enableProctoredExams,
+ isXBlockComponent,
}) => {
const intl = useIntl();
const {
@@ -57,8 +58,7 @@ const ConfigureModal = ({
supportsOnboarding,
showReviewRules,
onlineProctoringRules,
- } = useSelector(getCurrentItem);
- const enableProctoredExams = useSelector(getProctoredExamsFlag);
+ } = currentItemData;
const getSelectedGroups = () => {
if (userPartitionInfo?.selectedPartitionIndex >= 0) {
@@ -81,7 +81,6 @@ const ConfigureModal = ({
const initialValues = {
releaseDate: sectionStartDate,
isVisibleToStaffOnly: visibilityState === VisibilityTypes.STAFF_ONLY,
- saveButtonDisabled: true,
graderType: format == null ? 'notgraded' : format,
dueDate: due == null ? '' : due,
isTimeLimited,
@@ -132,6 +131,10 @@ const ConfigureModal = ({
const isSubsection = category === COURSE_BLOCK_NAMES.sequential.id;
+ const dialogTitle = isXBlockComponent
+ ? intl.formatMessage(messages.componentTitle, { title: displayName })
+ : intl.formatMessage(messages.title, { title: displayName });
+
const handleSave = (data) => {
const groupAccess = {};
switch (category) {
@@ -159,6 +162,7 @@ const ConfigureModal = ({
);
break;
case COURSE_BLOCK_NAMES.vertical.id:
+ case COURSE_BLOCK_NAMES.component.id:
// groupAccess should be {partitionId: [group1, group2]} or {} if selectedPartitionIndex === -1
if (data.selectedPartitionIndex >= 0) {
const partitionId = userPartitionInfo.selectablePartitions[data.selectedPartitionIndex].id;
@@ -232,8 +236,10 @@ const ConfigureModal = ({
);
case COURSE_BLOCK_NAMES.vertical.id:
+ case COURSE_BLOCK_NAMES.component.id:
return (
- {intl.formatMessage(messages.title, { title: displayName })}
+ {dialogTitle}
{({
- values, handleSubmit, dirty, isValid, setFieldValue,
+ values, handleSubmit, setFieldValue,
}) => (
<>
@@ -281,7 +287,10 @@ const ConfigureModal = ({
{intl.formatMessage(messages.cancelButton)}
-
+
{intl.formatMessage(messages.saveButton)}
@@ -294,10 +303,63 @@ const ConfigureModal = ({
);
};
+ConfigureModal.defaultProps = {
+ isXBlockComponent: false,
+ enableProctoredExams: false,
+};
+
ConfigureModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onConfigureSubmit: PropTypes.func.isRequired,
+ enableProctoredExams: PropTypes.bool,
+ currentItemData: PropTypes.shape({
+ displayName: PropTypes.string,
+ start: PropTypes.string,
+ visibilityState: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
+ due: PropTypes.string,
+ isTimeLimited: PropTypes.bool,
+ defaultTimeLimitMinutes: PropTypes.number,
+ hideAfterDue: PropTypes.bool,
+ showCorrectness: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
+ courseGraders: PropTypes.arrayOf(PropTypes.string),
+ category: PropTypes.string,
+ format: PropTypes.string,
+ userPartitionInfo: PropTypes.shape({
+ selectablePartitions: PropTypes.arrayOf(PropTypes.shape({
+ groups: PropTypes.arrayOf(PropTypes.shape({
+ deleted: PropTypes.bool,
+ id: PropTypes.number,
+ name: PropTypes.string,
+ selected: PropTypes.bool,
+ })),
+ id: PropTypes.number,
+ name: PropTypes.string,
+ scheme: PropTypes.string,
+ })),
+ selectedPartitionIndex: PropTypes.number,
+ selectedGroupsLabel: PropTypes.string,
+ }),
+ ancestorHasStaffLock: PropTypes.bool,
+ isPrereq: PropTypes.bool,
+ prereqs: PropTypes.arrayOf({
+ blockDisplayName: PropTypes.string,
+ blockUsageKey: PropTypes.string,
+ }),
+ prereq: PropTypes.number,
+ prereqMinScore: PropTypes.number,
+ prereqMinCompletion: PropTypes.number,
+ releasedToStudents: PropTypes.bool,
+ wasExamEverLinkedWithExternal: PropTypes.bool,
+ isProctoredExam: PropTypes.bool,
+ isOnboardingExam: PropTypes.bool,
+ isPracticeExam: PropTypes.bool,
+ examReviewRules: PropTypes.string,
+ supportsOnboarding: PropTypes.bool,
+ showReviewRules: PropTypes.bool,
+ onlineProctoringRules: PropTypes.string,
+ }).isRequired,
+ isXBlockComponent: PropTypes.bool,
};
export default ConfigureModal;
diff --git a/src/course-outline/configure-modal/ConfigureModal.scss b/src/generic/configure-modal/ConfigureModal.scss
similarity index 100%
rename from src/course-outline/configure-modal/ConfigureModal.scss
rename to src/generic/configure-modal/ConfigureModal.scss
diff --git a/src/course-outline/configure-modal/ConfigureModal.test.jsx b/src/generic/configure-modal/ConfigureModal.test.jsx
similarity index 56%
rename from src/course-outline/configure-modal/ConfigureModal.test.jsx
rename to src/generic/configure-modal/ConfigureModal.test.jsx
index 9756f32467..3c4d699446 100644
--- a/src/course-outline/configure-modal/ConfigureModal.test.jsx
+++ b/src/generic/configure-modal/ConfigureModal.test.jsx
@@ -1,7 +1,6 @@
-import React from 'react';
-import { render, fireEvent } from '@testing-library/react';
+import { render } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
-import { useSelector } from 'react-redux';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
@@ -9,6 +8,12 @@ import { AppProvider } from '@edx/frontend-platform/react';
import initializeStore from '../../store';
import ConfigureModal from './ConfigureModal';
+import {
+ currentSectionMock,
+ currentSubsectionMock,
+ currentUnitMock,
+ currentXBlockMock,
+} from './__mocks__';
import messages from './messages';
// eslint-disable-next-line no-unused-vars
@@ -28,79 +33,6 @@ jest.mock('react-router-dom', () => ({
}),
}));
-const currentSectionMock = {
- displayName: 'Section1',
- category: 'chapter',
- start: '2025-08-10T10:00:00Z',
- visibilityState: true,
- format: 'Not Graded',
- childInfo: {
- displayName: 'Subsection',
- children: [
- {
- displayName: 'Subsection 1',
- id: 1,
- category: 'sequential',
- due: '',
- start: '2025-08-10T10:00:00Z',
- visibilityState: true,
- defaultTimeLimitMinutes: null,
- hideAfterDue: false,
- showCorrectness: false,
- format: 'Homework',
- courseGraders: ['Homework', 'Exam'],
- childInfo: {
- displayName: 'Unit',
- children: [
- {
- id: 11,
- displayName: 'Subsection_1 Unit 1',
- },
- ],
- },
- },
- {
- displayName: 'Subsection 2',
- id: 2,
- category: 'sequential',
- due: '',
- start: '2025-08-10T10:00:00Z',
- visibilityState: true,
- defaultTimeLimitMinutes: null,
- hideAfterDue: false,
- showCorrectness: false,
- format: 'Homework',
- courseGraders: ['Homework', 'Exam'],
- childInfo: {
- displayName: 'Unit',
- children: [
- {
- id: 21,
- displayName: 'Subsection_2 Unit 1',
- },
- ],
- },
- },
- {
- displayName: 'Subsection 3',
- id: 3,
- category: 'sequential',
- due: '',
- start: '2025-08-10T10:00:00Z',
- visibilityState: true,
- defaultTimeLimitMinutes: null,
- hideAfterDue: false,
- showCorrectness: false,
- format: 'Homework',
- courseGraders: ['Homework', 'Exam'],
- childInfo: {
- children: [],
- },
- },
- ],
- },
-};
-
const onCloseMock = jest.fn();
const onConfigureSubmitMock = jest.fn();
@@ -111,6 +43,7 @@ const renderComponent = () => render(
isOpen
onClose={onCloseMock}
onConfigureSubmit={onConfigureSubmitMock}
+ currentItemData={currentSectionMock}
/>
,
,
@@ -129,12 +62,11 @@ describe(' for Section', () => {
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
- useSelector.mockReturnValue(currentSectionMock);
});
it('renders ConfigureModal component correctly', () => {
const { getByText, getByRole } = renderComponent();
- expect(getByText(`${currentSectionMock.displayName} Settings`)).toBeInTheDocument();
+ expect(getByText(`${currentSectionMock.displayName} settings`)).toBeInTheDocument();
expect(getByText(messages.basicTabTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.visibilityTabTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.releaseDate.defaultMessage)).toBeInTheDocument();
@@ -147,55 +79,12 @@ describe(' for Section', () => {
const { getByRole, getByText } = renderComponent();
const visibilityTab = getByRole('tab', { name: messages.visibilityTabTitle.defaultMessage });
- fireEvent.click(visibilityTab);
- expect(getByText('Section Visibility')).toBeInTheDocument();
+ userEvent.click(visibilityTab);
+ expect(getByText('Section visibility')).toBeInTheDocument();
expect(getByText(messages.hideFromLearners.defaultMessage)).toBeInTheDocument();
});
-
- it('disables the Save button and enables it if there is a change', () => {
- const { getByRole, getByPlaceholderText, getByTestId } = renderComponent();
-
- const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage });
- expect(saveButton).toBeDisabled();
-
- const input = getByPlaceholderText('MM/DD/YYYY');
- fireEvent.change(input, { target: { value: '12/15/2023' } });
-
- const visibilityTab = getByRole('tab', { name: messages.visibilityTabTitle.defaultMessage });
- fireEvent.click(visibilityTab);
- const checkbox = getByTestId('visibility-checkbox');
- fireEvent.click(checkbox);
- expect(saveButton).not.toBeDisabled();
- });
});
-const currentSubsectionMock = {
- displayName: 'Subsection 1',
- id: 1,
- category: 'sequential',
- due: '',
- start: '2025-08-10T10:00:00Z',
- visibilityState: true,
- defaultTimeLimitMinutes: null,
- hideAfterDue: false,
- showCorrectness: false,
- format: 'Homework',
- courseGraders: ['Homework', 'Exam'],
- childInfo: {
- displayName: 'Unit',
- children: [
- {
- id: 11,
- displayName: 'Subsection_1 Unit 1',
- },
- {
- id: 12,
- displayName: 'Subsection_1 Unit 2',
- },
- ],
- },
-};
-
const renderSubsectionComponent = () => render(
@@ -203,6 +92,7 @@ const renderSubsectionComponent = () => render(
isOpen
onClose={onCloseMock}
onConfigureSubmit={onConfigureSubmitMock}
+ currentItemData={currentSubsectionMock}
/>
,
,
@@ -221,12 +111,11 @@ describe(' for Subsection', () => {
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
- useSelector.mockReturnValue(currentSubsectionMock);
});
it('renders subsection ConfigureModal component correctly', () => {
const { getByText, getByRole } = renderSubsectionComponent();
- expect(getByText(`${currentSubsectionMock.displayName} Settings`)).toBeInTheDocument();
+ expect(getByText(`${currentSubsectionMock.displayName} settings`)).toBeInTheDocument();
expect(getByText(messages.basicTabTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.visibilityTabTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.advancedTabTitle.defaultMessage)).toBeInTheDocument();
@@ -244,8 +133,8 @@ describe(' for Subsection', () => {
const { getByRole, getByText } = renderSubsectionComponent();
const visibilityTab = getByRole('tab', { name: messages.visibilityTabTitle.defaultMessage });
- fireEvent.click(visibilityTab);
- expect(getByText('Subsection Visibility')).toBeInTheDocument();
+ userEvent.click(visibilityTab);
+ expect(getByText('Subsection visibility')).toBeInTheDocument();
expect(getByText(messages.showEntireSubsection.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.showEntireSubsectionDescription.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.hideContentAfterDue.defaultMessage)).toBeInTheDocument();
@@ -265,82 +154,23 @@ describe(' for Subsection', () => {
const { getByRole, getByText } = renderSubsectionComponent();
const advancedTab = getByRole('tab', { name: messages.advancedTabTitle.defaultMessage });
- fireEvent.click(advancedTab);
+ userEvent.click(advancedTab);
expect(getByText(messages.setSpecialExam.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.none.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.timed.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.timedDescription.defaultMessage)).toBeInTheDocument();
});
-
- it('disables the Save button and enables it if there is a change', () => {
- const { getByRole, getByTestId } = renderSubsectionComponent();
-
- const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage });
- expect(saveButton).toBeDisabled();
-
- const input = getByTestId('grader-type-select');
- fireEvent.change(input, { target: { value: 'Exam' } });
- expect(saveButton).not.toBeDisabled();
- });
});
-const currentUnitMock = {
- displayName: 'Unit 1',
- id: 1,
- category: 'vertical',
- due: '',
- start: '2025-08-10T10:00:00Z',
- visibilityState: true,
- defaultTimeLimitMinutes: null,
- hideAfterDue: false,
- showCorrectness: false,
- userPartitionInfo: {
- selectablePartitions: [
- {
- id: 50,
- name: 'Enrollment Track Groups',
- scheme: 'enrollment_track',
- groups: [
- {
- id: 6,
- name: 'Honor',
- selected: false,
- deleted: false,
- },
- {
- id: 2,
- name: 'Verified',
- selected: false,
- deleted: false,
- },
- ],
- },
- {
- id: 1508065533,
- name: 'Content Groups',
- scheme: 'cohort',
- groups: [
- {
- id: 1224170703,
- name: 'Content Group 1',
- selected: false,
- deleted: false,
- },
- ],
- },
- ],
- selectedPartitionIndex: -1,
- selectedGroupsLabel: '',
- },
-};
-
-const renderUnitComponent = () => render(
+const renderUnitComponent = (props) => render(
,
,
@@ -359,14 +189,13 @@ describe(' for Unit', () => {
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
- useSelector.mockReturnValue(currentUnitMock);
});
it('renders unit ConfigureModal component correctly', () => {
const {
getByText, queryByText, getByRole, getByTestId,
} = renderUnitComponent();
- expect(getByText(`${currentUnitMock.displayName} Settings`)).toBeInTheDocument();
+ expect(getByText(`${currentUnitMock.displayName} settings`)).toBeInTheDocument();
expect(getByText(messages.unitVisibility.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.hideFromLearners.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.restrictAccessTo.defaultMessage)).toBeInTheDocument();
@@ -375,8 +204,8 @@ describe(' for Unit', () => {
expect(queryByText(messages.unitSelectGroup.defaultMessage)).not.toBeInTheDocument();
const input = getByTestId('group-type-select');
- [0, 1].forEach(groupeTypeIndex => {
- fireEvent.change(input, { target: { value: groupeTypeIndex } });
+ ['0', '1'].forEach(groupeTypeIndex => {
+ userEvent.selectOptions(input, groupeTypeIndex);
expect(getByText(messages.unitSelectGroup.defaultMessage)).toBeInTheDocument();
currentUnitMock
@@ -388,32 +217,62 @@ describe(' for Unit', () => {
expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument();
});
+});
- it('disables the Save button and enables it if there is a change', () => {
- useSelector.mockReturnValue(
- {
- ...currentUnitMock,
- userPartitionInfo: {
- ...currentUnitMock.userPartitionInfo,
- selectedPartitionIndex: 0,
- },
+const renderXBlockComponent = (props) => render(
+
+
+
+ ,
+ ,
+);
+
+describe(' for XBlock', () => {
+ beforeEach(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
},
- );
- const { getByRole, getByTestId } = renderUnitComponent();
+ });
- const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage });
- expect(saveButton).toBeDisabled();
+ store = initializeStore();
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ });
+ it('renders unit ConfigureModal component correctly', () => {
+ const {
+ getByText, queryByText, getByRole, getByTestId,
+ } = renderXBlockComponent();
+ expect(getByText(`Editing access for: ${currentUnitMock.displayName}`)).toBeInTheDocument();
+ expect(queryByText(messages.unitVisibility.defaultMessage)).not.toBeInTheDocument();
+ expect(queryByText(messages.hideFromLearners.defaultMessage)).not.toBeInTheDocument();
+ expect(getByText(messages.restrictAccessTo.defaultMessage)).toBeInTheDocument();
+ expect(getByText(messages.unitSelectGroupType.defaultMessage)).toBeInTheDocument();
+
+ expect(queryByText(messages.unitSelectGroup.defaultMessage)).not.toBeInTheDocument();
const input = getByTestId('group-type-select');
- // unrestrict access
- fireEvent.change(input, { target: { value: -1 } });
- expect(saveButton).not.toBeDisabled();
- fireEvent.change(input, { target: { value: 0 } });
- expect(saveButton).toBeDisabled();
+ ['0', '1'].forEach(groupeTypeIndex => {
+ userEvent.selectOptions(input, groupeTypeIndex);
+
+ expect(getByText(messages.unitSelectGroup.defaultMessage)).toBeInTheDocument();
+ currentUnitMock
+ .userPartitionInfo
+ .selectablePartitions[groupeTypeIndex].groups
+ .forEach(g => expect(getByText(g.name)).toBeInTheDocument());
+ });
- const checkbox = getByTestId('unit-visibility-checkbox');
- fireEvent.click(checkbox);
- expect(saveButton).not.toBeDisabled();
+ expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
+ expect(getByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument();
});
});
diff --git a/src/course-outline/configure-modal/PrereqSettings.jsx b/src/generic/configure-modal/PrereqSettings.jsx
similarity index 98%
rename from src/course-outline/configure-modal/PrereqSettings.jsx
rename to src/generic/configure-modal/PrereqSettings.jsx
index b79ffbf34d..74c5f7148e 100644
--- a/src/course-outline/configure-modal/PrereqSettings.jsx
+++ b/src/generic/configure-modal/PrereqSettings.jsx
@@ -4,7 +4,7 @@ import { Form } from '@openedx/paragon';
import { FormattedMessage, injectIntl, useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
-import FormikControl from '../../generic/FormikControl';
+import FormikControl from '../FormikControl';
const PrereqSettings = ({
values,
diff --git a/src/course-outline/configure-modal/UnitTab.jsx b/src/generic/configure-modal/UnitTab.jsx
similarity index 70%
rename from src/course-outline/configure-modal/UnitTab.jsx
rename to src/generic/configure-modal/UnitTab.jsx
index ec838711da..2c38ab17d0 100644
--- a/src/course-outline/configure-modal/UnitTab.jsx
+++ b/src/generic/configure-modal/UnitTab.jsx
@@ -5,10 +5,12 @@ import {
FormattedMessage, injectIntl, useIntl,
} from '@edx/frontend-platform/i18n';
import { Field } from 'formik';
+import classNames from 'classnames';
import messages from './messages';
const UnitTab = ({
+ isXBlockComponent,
values,
setFieldValue,
showWarning,
@@ -18,6 +20,7 @@ const UnitTab = ({
const {
isVisibleToStaffOnly,
selectedPartitionIndex,
+ selectedGroups,
} = values;
const handleChange = (e) => {
@@ -26,21 +29,32 @@ const UnitTab = ({
const handleSelect = (e) => {
setFieldValue('selectedPartitionIndex', parseInt(e.target.value, 10));
+ setFieldValue('selectedGroups', []);
+ };
+
+ const checkIsDeletedGroup = (group) => {
+ const isGroupSelected = selectedGroups.includes(group.id.toString());
+
+ return group.deleted && isGroupSelected;
};
return (
<>
-
-
-
-
-
- {showWarning && (
-
-
-
+ {!isXBlockComponent && (
+ <>
+
+
+
+
+
+ {showWarning && (
+
+
+
+ )}
+
+ >
)}
-
@@ -89,9 +103,19 @@ const UnitTab = ({
value={`${group.id}`}
name="selectedGroups"
/>
-
- {group.name}
-
+
+
+ {group.name}
+
+ {group.deleted && (
+
+ {intl.formatMessage(messages.unitSelectDeletedGroupErrorMessage)}
+
+ )}
+
))}
@@ -103,13 +127,21 @@ const UnitTab = ({
);
};
+UnitTab.defaultProps = {
+ isXBlockComponent: false,
+};
+
UnitTab.propTypes = {
+ isXBlockComponent: PropTypes.bool,
values: PropTypes.shape({
isVisibleToStaffOnly: PropTypes.bool.isRequired,
selectedPartitionIndex: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]).isRequired,
+ selectedGroups: PropTypes.oneOfType([
+ PropTypes.string,
+ ]),
}).isRequired,
setFieldValue: PropTypes.func.isRequired,
showWarning: PropTypes.bool.isRequired,
diff --git a/src/course-outline/configure-modal/VisibilityTab.jsx b/src/generic/configure-modal/VisibilityTab.jsx
similarity index 98%
rename from src/course-outline/configure-modal/VisibilityTab.jsx
rename to src/generic/configure-modal/VisibilityTab.jsx
index 44ee964619..c6ce99da4d 100644
--- a/src/course-outline/configure-modal/VisibilityTab.jsx
+++ b/src/generic/configure-modal/VisibilityTab.jsx
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { Alert, Form } from '@openedx/paragon';
import { FormattedMessage, injectIntl, useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
-import { COURSE_BLOCK_NAMES } from '../constants';
+import { COURSE_BLOCK_NAMES } from '../../constants';
const VisibilityTab = ({
values,
diff --git a/src/generic/configure-modal/__mocks__/index.js b/src/generic/configure-modal/__mocks__/index.js
new file mode 100644
index 0000000000..8e69d242d9
--- /dev/null
+++ b/src/generic/configure-modal/__mocks__/index.js
@@ -0,0 +1,199 @@
+export const currentSectionMock = {
+ displayName: 'Section1',
+ category: 'chapter',
+ start: '2025-08-10T10:00:00Z',
+ visibilityState: true,
+ format: 'Not Graded',
+ childInfo: {
+ displayName: 'Subsection',
+ children: [
+ {
+ displayName: 'Subsection 1',
+ id: 1,
+ category: 'sequential',
+ due: '',
+ start: '2025-08-10T10:00:00Z',
+ visibilityState: true,
+ defaultTimeLimitMinutes: null,
+ hideAfterDue: false,
+ showCorrectness: false,
+ format: 'Homework',
+ courseGraders: ['Homework', 'Exam'],
+ childInfo: {
+ displayName: 'Unit',
+ children: [
+ {
+ id: 11,
+ displayName: 'Subsection_1 Unit 1',
+ },
+ ],
+ },
+ },
+ {
+ displayName: 'Subsection 2',
+ id: 2,
+ category: 'sequential',
+ due: '',
+ start: '2025-08-10T10:00:00Z',
+ visibilityState: true,
+ defaultTimeLimitMinutes: null,
+ hideAfterDue: false,
+ showCorrectness: false,
+ format: 'Homework',
+ courseGraders: ['Homework', 'Exam'],
+ childInfo: {
+ displayName: 'Unit',
+ children: [
+ {
+ id: 21,
+ displayName: 'Subsection_2 Unit 1',
+ },
+ ],
+ },
+ },
+ {
+ displayName: 'Subsection 3',
+ id: 3,
+ category: 'sequential',
+ due: '',
+ start: '2025-08-10T10:00:00Z',
+ visibilityState: true,
+ defaultTimeLimitMinutes: null,
+ hideAfterDue: false,
+ showCorrectness: false,
+ format: 'Homework',
+ courseGraders: ['Homework', 'Exam'],
+ childInfo: {
+ children: [],
+ },
+ },
+ ],
+ },
+};
+
+export const currentSubsectionMock = {
+ displayName: 'Subsection 1',
+ id: 1,
+ category: 'sequential',
+ due: '',
+ start: '2025-08-10T10:00:00Z',
+ visibilityState: true,
+ defaultTimeLimitMinutes: null,
+ hideAfterDue: false,
+ showCorrectness: false,
+ format: 'Homework',
+ courseGraders: ['Homework', 'Exam'],
+ childInfo: {
+ displayName: 'Unit',
+ children: [
+ {
+ id: 11,
+ displayName: 'Subsection_1 Unit 1',
+ },
+ {
+ id: 12,
+ displayName: 'Subsection_1 Unit 2',
+ },
+ ],
+ },
+};
+
+export const currentUnitMock = {
+ displayName: 'Unit 1',
+ id: 1,
+ category: 'vertical',
+ due: '',
+ start: '2025-08-10T10:00:00Z',
+ visibilityState: true,
+ defaultTimeLimitMinutes: null,
+ hideAfterDue: false,
+ showCorrectness: false,
+ userPartitionInfo: {
+ selectablePartitions: [
+ {
+ id: 50,
+ name: 'Enrollment Track Groups',
+ scheme: 'enrollment_track',
+ groups: [
+ {
+ id: 6,
+ name: 'Honor',
+ selected: false,
+ deleted: false,
+ },
+ {
+ id: 2,
+ name: 'Verified',
+ selected: false,
+ deleted: false,
+ },
+ ],
+ },
+ {
+ id: 1508065533,
+ name: 'Content Groups',
+ scheme: 'cohort',
+ groups: [
+ {
+ id: 1224170703,
+ name: 'Content Group 1',
+ selected: false,
+ deleted: false,
+ },
+ ],
+ },
+ ],
+ selectedPartitionIndex: -1,
+ selectedGroupsLabel: '',
+ },
+};
+
+export const currentXBlockMock = {
+ displayName: 'Unit 1',
+ id: 1,
+ category: 'component',
+ due: '',
+ start: '2025-08-10T10:00:00Z',
+ visibilityState: true,
+ defaultTimeLimitMinutes: null,
+ hideAfterDue: false,
+ showCorrectness: false,
+ userPartitionInfo: {
+ selectablePartitions: [
+ {
+ id: 50,
+ name: 'Enrollment Track Groups',
+ scheme: 'enrollment_track',
+ groups: [
+ {
+ id: 6,
+ name: 'Honor',
+ selected: false,
+ deleted: false,
+ },
+ {
+ id: 2,
+ name: 'Verified',
+ selected: false,
+ deleted: false,
+ },
+ ],
+ },
+ {
+ id: 1508065533,
+ name: 'Content Groups',
+ scheme: 'cohort',
+ groups: [
+ {
+ id: 1224170703,
+ name: 'Content Group 1',
+ selected: false,
+ deleted: false,
+ },
+ ],
+ },
+ ],
+ selectedPartitionIndex: -1,
+ selectedGroupsLabel: '',
+ },
+};
diff --git a/src/course-outline/configure-modal/messages.js b/src/generic/configure-modal/messages.js
similarity index 93%
rename from src/course-outline/configure-modal/messages.js
rename to src/generic/configure-modal/messages.js
index 316cbc0fb0..d27f943dc6 100644
--- a/src/course-outline/configure-modal/messages.js
+++ b/src/generic/configure-modal/messages.js
@@ -3,7 +3,12 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
title: {
id: 'course-authoring.course-outline.configure-modal.title',
- defaultMessage: '{title} Settings',
+ defaultMessage: '{title} settings',
+ },
+ componentTitle: {
+ id: 'course-authoring.course-outline.configure-modal.component.title',
+ defaultMessage: 'Editing access for: {title}',
+ description: 'The visibility modal title for unit',
},
basicTabTitle: {
id: 'course-authoring.course-outline.configure-modal.basic-tab.title',
@@ -15,15 +20,15 @@ const messages = defineMessages({
},
releaseDateAndTime: {
id: 'course-authoring.course-outline.configure-modal.basic-tab.release-date-and-time',
- defaultMessage: 'Release Date and Time',
+ defaultMessage: 'Release date and time',
},
releaseDate: {
id: 'course-authoring.course-outline.configure-modal.basic-tab.release-date',
- defaultMessage: 'Release Date:',
+ defaultMessage: 'Release date:',
},
releaseTimeUTC: {
id: 'course-authoring.course-outline.configure-modal.basic-tab.release-time-UTC',
- defaultMessage: 'Release Time in UTC:',
+ defaultMessage: 'Release time in UTC:',
},
visibilityTabTitle: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.title',
@@ -31,11 +36,11 @@ const messages = defineMessages({
},
visibilitySectionTitle: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.section-visibility',
- defaultMessage: '{visibilityTitle} Visibility',
+ defaultMessage: '{visibilityTitle} visibility',
},
unitVisibility: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.unit-visibility',
- defaultMessage: 'Unit Visibility',
+ defaultMessage: 'Unit visibility',
},
hideFromLearners: {
id: 'course-authoring.course-outline.configure-modal.visibility.hide-from-learners',
@@ -65,6 +70,11 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.configure-modal.unit-tab.unit-select-group-type',
defaultMessage: 'Select a group type',
},
+ unitSelectDeletedGroupErrorMessage: {
+ id: 'course-authoring.course-outline.configure-modal.unit-tab.unit-select-group-deleted-error-message',
+ defaultMessage: 'This group no longer exists. Choose another group or remove the access restriction.',
+ description: 'The alert text of no longer available group',
+ },
unitAllLearnersAndStaff: {
id: 'course-authoring.course-outline.configure-modal.unit-tab.unit-all-learners-staff',
defaultMessage: 'All Learners and Staff',
@@ -87,15 +97,15 @@ const messages = defineMessages({
},
dueDate: {
id: 'course-authoring.course-outline.configure-modal.basic-tab.due-date',
- defaultMessage: 'Due Date:',
+ defaultMessage: 'Due date:',
},
dueTimeUTC: {
id: 'course-authoring.course-outline.configure-modal.basic-tab.due-time-UTC',
- defaultMessage: 'Due Time in UTC:',
+ defaultMessage: 'Due time in UTC:',
},
subsectionVisibility: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.subsection-visibility',
- defaultMessage: 'Subsection Visibility',
+ defaultMessage: 'Subsection visibility',
},
showEntireSubsection: {
id: 'course-authoring.course-outline.configure-modal.visibility-tab.show-entire-subsection',
@@ -151,7 +161,7 @@ const messages = defineMessages({
},
setSpecialExam: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.set-special-exam',
- defaultMessage: 'Set as a Special Exam',
+ defaultMessage: 'Set as a special exam',
},
none: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.none',
@@ -195,7 +205,7 @@ const messages = defineMessages({
},
timeAllotted: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.time-allotted',
- defaultMessage: 'Time Allotted (HH:MM):',
+ defaultMessage: 'Time allotted (HH:MM):',
},
timeLimitDescription: {
id: 'course-authoring.course-outline.configure-modal.advanced-tab.time-limit-description',
diff --git a/src/generic/prompt-if-dirty/PromptIfDirty.jsx b/src/generic/prompt-if-dirty/PromptIfDirty.jsx
new file mode 100644
index 0000000000..a686ea2e87
--- /dev/null
+++ b/src/generic/prompt-if-dirty/PromptIfDirty.jsx
@@ -0,0 +1,24 @@
+import { useEffect } from 'react';
+import PropTypes from 'prop-types';
+
+const PromptIfDirty = ({ dirty }) => {
+ useEffect(() => {
+ // eslint-disable-next-line consistent-return
+ const handleBeforeUnload = (event) => {
+ if (dirty) {
+ event.preventDefault();
+ }
+ };
+ window.addEventListener('beforeunload', handleBeforeUnload);
+
+ return () => {
+ window.removeEventListener('beforeunload', handleBeforeUnload);
+ };
+ }, [dirty]);
+
+ return null;
+};
+PromptIfDirty.propTypes = {
+ dirty: PropTypes.bool.isRequired,
+};
+export default PromptIfDirty;
diff --git a/src/generic/prompt-if-dirty/PromptIfDirty.test.jsx b/src/generic/prompt-if-dirty/PromptIfDirty.test.jsx
new file mode 100644
index 0000000000..b429a7e137
--- /dev/null
+++ b/src/generic/prompt-if-dirty/PromptIfDirty.test.jsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import { render, unmountComponentAtNode } from 'react-dom';
+import { act } from 'react-dom/test-utils';
+import PromptIfDirty from './PromptIfDirty';
+
+describe('PromptIfDirty', () => {
+ let container = null;
+ let mockEvent = null;
+
+ beforeEach(() => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ mockEvent = new Event('beforeunload');
+ jest.spyOn(window, 'addEventListener');
+ jest.spyOn(window, 'removeEventListener');
+ jest.spyOn(mockEvent, 'preventDefault');
+ Object.defineProperty(mockEvent, 'returnValue', { writable: true });
+ mockEvent.returnValue = '';
+ });
+
+ afterEach(() => {
+ window.addEventListener.mockRestore();
+ window.removeEventListener.mockRestore();
+ mockEvent.preventDefault.mockRestore();
+ mockEvent = null;
+ unmountComponentAtNode(container);
+ container.remove();
+ container = null;
+ });
+
+ it('should add event listener on mount', () => {
+ act(() => {
+ render( , container);
+ });
+
+ expect(window.addEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function));
+ });
+
+ it('should remove event listener on unmount', () => {
+ act(() => {
+ render( , container);
+ });
+ act(() => {
+ unmountComponentAtNode(container);
+ });
+
+ expect(window.removeEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function));
+ });
+
+ it('should call preventDefault and set returnValue when dirty is true', () => {
+ act(() => {
+ render( , container);
+ });
+ act(() => {
+ window.dispatchEvent(mockEvent);
+ });
+
+ expect(mockEvent.preventDefault).toHaveBeenCalled();
+ expect(mockEvent.returnValue).toBe('');
+ });
+
+ it('should not call preventDefault when dirty is false', () => {
+ act(() => {
+ render( , container);
+ });
+ act(() => {
+ window.dispatchEvent(mockEvent);
+ });
+
+ expect(mockEvent.preventDefault).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/generic/styles.scss b/src/generic/styles.scss
index 71166f7f1b..0ef6a6202e 100644
--- a/src/generic/styles.scss
+++ b/src/generic/styles.scss
@@ -8,3 +8,4 @@
@import "./course-stepper/CouseStepper";
@import "./tag-count/TagCount";
@import "./modal-dropzone/ModalDropzone";
+@import "./configure-modal/ConfigureModal";
diff --git a/src/group-configurations/GroupConfigurations.scss b/src/group-configurations/GroupConfigurations.scss
new file mode 100644
index 0000000000..cc1620a814
--- /dev/null
+++ b/src/group-configurations/GroupConfigurations.scss
@@ -0,0 +1,130 @@
+@import "./empty-placeholder/EmptyPlaceholder";
+
+.configuration-section-name {
+ text-transform: lowercase;
+
+ &::first-letter {
+ text-transform: capitalize;
+ }
+
+ .group-percentage-container {
+ width: 1rem;
+ }
+}
+
+.configuration-card {
+ @include pgn-box-shadow(1, "down");
+
+ background: $white;
+ border-radius: .375rem;
+ padding: map-get($spacers, 4);
+ margin-bottom: map-get($spacers, 4);
+
+ .configuration-card-header {
+ display: flex;
+ align-items: center;
+ align-content: center;
+ justify-content: space-between;
+
+ .configuration-card-header__button {
+ display: flex;
+ align-items: flex-start;
+ padding: 0;
+ height: auto;
+ color: $black;
+
+ &:focus::before {
+ display: none;
+ }
+
+ .pgn__icon {
+ display: inline-block;
+ margin-right: map-get($spacers, 1);
+ margin-bottom: map-get($spacers, 2\.5);
+ }
+
+ .pgn__hstack {
+ align-items: baseline;
+ }
+
+ &:hover {
+ background: transparent;
+ }
+ }
+
+ .configuration-card-header__title {
+ text-align: left;
+
+ h3 {
+ margin-bottom: map-get($spacers, 2);
+ }
+ }
+
+ .configuration-card-header__badge {
+ display: flex;
+ padding: .125rem map-get($spacers, 2);
+ justify-content: center;
+ align-items: center;
+ border-radius: $border-radius;
+ border: .063rem solid $light-300;
+ background: $white;
+
+ &:first-child {
+ margin-left: map-get($spacers, 2\.5);
+ }
+
+ & span:last-child {
+ color: $primary-700;
+ }
+ }
+
+ .configuration-card-header__delete-tooltip {
+ pointer-events: all;
+ }
+ }
+
+ .configuration-card-content {
+ margin: 0 map-get($spacers, 2) 0 map-get($spacers, 4);
+
+ .configuration-card-content__experiment-stack {
+ display: flex;
+ justify-content: space-between;
+ padding: map-get($spacers, 2\.5) 0;
+ margin: 0;
+ color: $primary-500;
+ gap: $spacer;
+
+ &:not(:last-child) {
+ border-bottom: .063rem solid $light-400;
+ }
+ }
+ }
+
+ .pgn__form-control-decorator-group {
+ margin-inline-end: 0;
+ }
+
+ .configuration-form-group {
+ .pgn__form-label {
+ font: normal $font-weight-bold .875rem/1.25rem $font-family-base;
+ color: $gray-700;
+ margin-bottom: .875rem;
+ }
+
+ .pgn__form-control-description,
+ .pgn__form-text {
+ font: normal $font-weight-normal .75rem/1.25rem $font-family-base;
+ color: $gray-500;
+ margin-top: .625rem;
+ }
+
+ .pgn__form-text-invalid {
+ color: $form-feedback-invalid-color;
+ }
+ }
+
+ .experiment-configuration-form-percentage {
+ width: 5rem;
+ text-align: center;
+ }
+}
diff --git a/src/group-configurations/GroupConfigurations.test.jsx b/src/group-configurations/GroupConfigurations.test.jsx
new file mode 100644
index 0000000000..34486c368b
--- /dev/null
+++ b/src/group-configurations/GroupConfigurations.test.jsx
@@ -0,0 +1,106 @@
+import MockAdapter from 'axios-mock-adapter';
+import { render, waitFor, within } from '@testing-library/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { AppProvider } from '@edx/frontend-platform/react';
+import { initializeMockApp } from '@edx/frontend-platform';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+
+import { RequestStatus } from '../data/constants';
+import initializeStore from '../store';
+import { executeThunk } from '../utils';
+import { getContentStoreApiUrl } from './data/api';
+import { fetchGroupConfigurationsQuery } from './data/thunk';
+import { groupConfigurationResponseMock } from './__mocks__';
+import messages from './messages';
+import experimentMessages from './experiment-configurations-section/messages';
+import contentGroupsMessages from './content-groups-section/messages';
+import GroupConfigurations from '.';
+
+let axiosMock;
+let store;
+const courseId = 'course-v1:org+101+101';
+const enrollmentTrackGroups = groupConfigurationResponseMock.allGroupConfigurations[0];
+const contentGroups = groupConfigurationResponseMock.allGroupConfigurations[1];
+
+const renderComponent = () => render(
+
+
+
+
+ ,
+);
+
+describe(' ', () => {
+ beforeEach(async () => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ },
+ });
+
+ store = initializeStore();
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ axiosMock
+ .onGet(getContentStoreApiUrl(courseId))
+ .reply(200, groupConfigurationResponseMock);
+ await executeThunk(fetchGroupConfigurationsQuery(courseId), store.dispatch);
+ });
+
+ it('renders component correctly', async () => {
+ const { getByText, getAllByText, getByTestId } = renderComponent();
+
+ await waitFor(() => {
+ const mainContent = getByTestId('group-configurations-main-content-wrapper');
+ const groupConfigurationsElements = getAllByText(messages.headingTitle.defaultMessage);
+ const groupConfigurationsTitle = groupConfigurationsElements[0];
+
+ expect(groupConfigurationsTitle).toBeInTheDocument();
+ expect(
+ getByText(messages.headingSubtitle.defaultMessage),
+ ).toBeInTheDocument();
+ expect(
+ within(mainContent).getByText(contentGroupsMessages.addNewGroup.defaultMessage),
+ ).toBeInTheDocument();
+ expect(
+ within(mainContent).getByText(experimentMessages.addNewGroup.defaultMessage),
+ ).toBeInTheDocument();
+ expect(
+ within(mainContent).getByText(experimentMessages.title.defaultMessage),
+ ).toBeInTheDocument();
+ expect(getByText(contentGroups.name)).toBeInTheDocument();
+ expect(getByText(enrollmentTrackGroups.name)).toBeInTheDocument();
+ });
+ });
+
+ it('does not render an empty section for enrollment track groups if it is empty', () => {
+ const shouldNotShowEnrollmentTrackResponse = {
+ ...groupConfigurationResponseMock,
+ shouldShowEnrollmentTrack: false,
+ };
+ axiosMock
+ .onGet(getContentStoreApiUrl(courseId))
+ .reply(200, shouldNotShowEnrollmentTrackResponse);
+
+ const { queryByTestId } = renderComponent();
+ expect(
+ queryByTestId('group-configurations-empty-placeholder'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('updates loading status if request fails', async () => {
+ axiosMock
+ .onGet(getContentStoreApiUrl(courseId))
+ .reply(404, groupConfigurationResponseMock);
+
+ renderComponent();
+
+ await executeThunk(fetchGroupConfigurationsQuery(courseId), store.dispatch);
+
+ expect(store.getState().groupConfigurations.loadingStatus).toBe(
+ RequestStatus.FAILED,
+ );
+ });
+});
diff --git a/src/group-configurations/__mocks__/contentGroupsMock.js b/src/group-configurations/__mocks__/contentGroupsMock.js
new file mode 100644
index 0000000000..3f2ea7be21
--- /dev/null
+++ b/src/group-configurations/__mocks__/contentGroupsMock.js
@@ -0,0 +1,44 @@
+module.exports = {
+ active: true,
+ description: 'The groups in this configuration can be mapped to cohorts in the Instructor Dashboard.',
+ groups: [
+ {
+ id: 593758473,
+ name: 'My Content Group 1',
+ usage: [],
+ version: 1,
+ },
+ {
+ id: 256741177,
+ name: 'My Content Group 2',
+ usage: [
+ {
+ label: 'Unit / Blank Problem',
+ url: '/container/block-v1:org+101+101+type@vertical+block@3d6d82348e2743b6ac36ac4af354de0e',
+ },
+ {
+ label: 'Unit / Drag and Drop',
+ url: '/container/block-v1:org+101+101+type@vertical+block@3d6d82348w2743b6ac36ac4af354de0e',
+ },
+ ],
+ version: 1,
+ },
+ {
+ id: 646686987,
+ name: 'My Content Group 3',
+ usage: [
+ {
+ label: 'Unit / Drag and Drop',
+ url: '/container/block-v1:org+101+101+type@vertical+block@3d6d82348e2743b6ac36ac4af354de0e',
+ },
+ ],
+ version: 1,
+ },
+ ],
+ id: 1791848226,
+ name: 'Content Groups',
+ parameters: {},
+ readOnly: false,
+ scheme: 'cohort',
+ version: 3,
+};
diff --git a/src/group-configurations/__mocks__/enrollmentTrackGroupsMock.js b/src/group-configurations/__mocks__/enrollmentTrackGroupsMock.js
new file mode 100644
index 0000000000..654ff900f2
--- /dev/null
+++ b/src/group-configurations/__mocks__/enrollmentTrackGroupsMock.js
@@ -0,0 +1,32 @@
+module.exports = {
+ active: true,
+ description: 'Partition for segmenting users by enrollment track',
+ groups: [
+ {
+ id: 6,
+ name: '1111',
+ usage: [],
+ version: 1,
+ },
+ {
+ id: 2,
+ name: 'Enrollment track group',
+ usage: [
+ {
+ label: 'Subsection / Unit',
+ url: '/container/block-v1:org+101+101+type@vertical+block@08772238547242848cef928ba6446a55',
+ },
+ ],
+ version: 1,
+ },
+ ],
+ id: 50,
+ usage: null,
+ name: 'Enrollment Track Groups',
+ parameters: {
+ course_id: 'course-v1:org+101+101',
+ },
+ read_only: true,
+ scheme: 'enrollment_track',
+ version: 3,
+};
diff --git a/src/group-configurations/__mocks__/experimentGroupConfigurationsMock.js b/src/group-configurations/__mocks__/experimentGroupConfigurationsMock.js
new file mode 100644
index 0000000000..ab2356e744
--- /dev/null
+++ b/src/group-configurations/__mocks__/experimentGroupConfigurationsMock.js
@@ -0,0 +1,79 @@
+module.exports = [
+ {
+ active: true,
+ description: 'description',
+ groups: [
+ {
+ id: 276408623,
+ name: 'Group A',
+ usage: null,
+ version: 1,
+ },
+ {
+ id: 805061364,
+ name: 'Group B',
+ usage: null,
+ version: 1,
+ },
+ {
+ id: 1919501026,
+ name: 'Group C1',
+ usage: null,
+ version: 1,
+ },
+ ],
+ id: 875961582,
+ name: 'Experiment Group Configurations 1',
+ parameters: {},
+ scheme: 'random',
+ version: 3,
+ usage: [
+ {
+ label: 'Unit1name / Content Experiment',
+ url: '/container/block-v1:2u+1+1+type@split_test+block@ccfae830ec9b406c835f8ce4520ae395',
+ },
+ ],
+ },
+ {
+ active: true,
+ description: 'description',
+ groups: [
+ {
+ id: 1712898629,
+ name: 'Group M',
+ usage: null,
+ version: 1,
+ },
+ {
+ id: 374655043,
+ name: 'Group N',
+ usage: null,
+ version: 1,
+ },
+ {
+ id: 997016182,
+ name: 'Group O',
+ usage: null,
+ version: 1,
+ },
+ {
+ id: 361314468,
+ name: 'Group P',
+ usage: null,
+ version: 1,
+ },
+ {
+ id: 505101805,
+ name: 'Group Q',
+ usage: null,
+ version: 1,
+ },
+ ],
+ id: 996450752,
+ name: 'Experiment Group Configurations 2',
+ parameters: {},
+ scheme: 'random',
+ version: 3,
+ usage: [],
+ },
+];
diff --git a/src/group-configurations/__mocks__/groupConfigurationResponseMock.js b/src/group-configurations/__mocks__/groupConfigurationResponseMock.js
new file mode 100644
index 0000000000..b7f5540697
--- /dev/null
+++ b/src/group-configurations/__mocks__/groupConfigurationResponseMock.js
@@ -0,0 +1,149 @@
+module.exports = {
+ allGroupConfigurations: [
+ {
+ active: true,
+ description: 'Partition for segmenting users by enrollment track',
+ groups: [
+ {
+ id: 6,
+ name: '1111',
+ usage: [],
+ version: 1,
+ },
+ {
+ id: 2,
+ name: 'Enrollment track group',
+ usage: [
+ {
+ label: 'Subsection / Unit',
+ url: '/container/block-v1:org+101+101+type@vertical+block@08772238547242848cef928ba6446a55',
+ },
+ ],
+ version: 1,
+ },
+ ],
+ id: 50,
+ usage: null,
+ name: 'Enrollment Track Groups',
+ parameters: {
+ course_id: 'course-v1:org+101+101',
+ },
+ read_only: true,
+ scheme: 'enrollment_track',
+ version: 3,
+ },
+ {
+ active: true,
+ description:
+ 'The groups in this configuration can be mapped to cohorts in the Instructor Dashboard.',
+ groups: [
+ {
+ id: 593758473,
+ name: 'My Content Group 1',
+ usage: [],
+ version: 1,
+ },
+ {
+ id: 256741177,
+ name: 'My Content Group 2',
+ usage: [],
+ version: 1,
+ },
+ {
+ id: 646686987,
+ name: 'My Content Group 3',
+ usage: [
+ {
+ label: 'Unit / Drag and Drop',
+ url: '/container/block-v1:org+101+101+type@vertical+block@3d6d82348e2743b6ac36ac4af354de0e',
+ },
+ ],
+ version: 1,
+ },
+ ],
+ id: 1791848226,
+ name: 'Content Groups',
+ parameters: {},
+ readOnly: false,
+ scheme: 'cohort',
+ version: 3,
+ },
+ ],
+ experimentGroupConfigurations: [
+ {
+ active: true,
+ description: 'description',
+ groups: [
+ {
+ id: 276408623,
+ name: 'Group A',
+ usage: null,
+ version: 1,
+ },
+ {
+ id: 805061364,
+ name: 'Group B',
+ usage: null,
+ version: 1,
+ },
+ {
+ id: 1919501026,
+ name: 'Group C1',
+ usage: null,
+ version: 1,
+ },
+ ],
+ id: 875961582,
+ name: 'Experiment Group Configurations 5',
+ parameters: {},
+ scheme: 'random',
+ version: 3,
+ usage: [],
+ },
+ {
+ active: true,
+ description: 'description',
+ groups: [
+ {
+ id: 1712898629,
+ name: 'Group M',
+ usage: null,
+ version: 1,
+ },
+ {
+ id: 374655043,
+ name: 'Group N',
+ usage: null,
+ version: 1,
+ },
+ {
+ id: 997016182,
+ name: 'Group O',
+ usage: null,
+ version: 1,
+ },
+ {
+ id: 361314468,
+ name: 'Group P',
+ usage: null,
+ version: 1,
+ },
+ {
+ id: 505101805,
+ name: 'Group Q',
+ usage: null,
+ version: 1,
+ },
+ ],
+ id: 996450752,
+ name: 'Experiment Group Configurations 4',
+ parameters: {},
+ scheme: 'random',
+ version: 3,
+ usage: [],
+ },
+ ],
+ mfeProctoredExamSettingsUrl: '',
+ shouldShowEnrollmentTrack: true,
+ shouldShowExperimentGroups: true,
+};
diff --git a/src/group-configurations/__mocks__/index.js b/src/group-configurations/__mocks__/index.js
new file mode 100644
index 0000000000..bb3f889849
--- /dev/null
+++ b/src/group-configurations/__mocks__/index.js
@@ -0,0 +1,4 @@
+export { default as contentGroupsMock } from './contentGroupsMock';
+export { default as enrollmentTrackGroupsMock } from './enrollmentTrackGroupsMock';
+export { default as experimentGroupConfigurationsMock } from './experimentGroupConfigurationsMock';
+export { default as groupConfigurationResponseMock } from './groupConfigurationResponseMock';
diff --git a/src/group-configurations/common/TitleButton.jsx b/src/group-configurations/common/TitleButton.jsx
new file mode 100644
index 0000000000..87d5d50016
--- /dev/null
+++ b/src/group-configurations/common/TitleButton.jsx
@@ -0,0 +1,100 @@
+import PropTypes from 'prop-types';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import {
+ Button, Stack, Badge, Truncate,
+} from '@openedx/paragon';
+import {
+ ArrowDropDown as ArrowDownIcon,
+ ArrowRight as ArrowRightIcon,
+} from '@openedx/paragon/icons';
+
+import { getCombinedBadgeList } from '../utils';
+import messages from './messages';
+
+const TitleButton = ({
+ group, isExpanded, isExperiment, onTitleClick,
+}) => {
+ const { formatMessage } = useIntl();
+ const { id, name, usage } = group;
+
+ return (
+
+
+
+ {name}
+
+
+ {formatMessage(messages.titleId, { id })}
+
+
+ {!isExpanded && (
+
+ {getCombinedBadgeList(usage, group, isExperiment, formatMessage).map(
+ (badge) => (
+
+ {badge}
+
+ ),
+ )}
+
+ )}
+
+ );
+};
+
+TitleButton.defaultProps = {
+ isExperiment: false,
+};
+
+TitleButton.propTypes = {
+ group: PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ usage: PropTypes.arrayOf(
+ PropTypes.shape({
+ label: PropTypes.string,
+ url: PropTypes.string,
+ }),
+ ),
+ version: PropTypes.number.isRequired,
+ active: PropTypes.bool,
+ description: PropTypes.string,
+ groups: PropTypes.arrayOf(
+ PropTypes.shape({
+ id: PropTypes.number,
+ name: PropTypes.string,
+ usage: PropTypes.arrayOf(
+ PropTypes.shape({
+ label: PropTypes.string,
+ url: PropTypes.string,
+ }),
+ ),
+ version: PropTypes.number,
+ }),
+ ),
+ parameters: PropTypes.shape({
+ courseId: PropTypes.string,
+ }),
+ readOnly: PropTypes.bool,
+ scheme: PropTypes.string,
+ }).isRequired,
+ isExpanded: PropTypes.bool.isRequired,
+ isExperiment: PropTypes.bool,
+ onTitleClick: PropTypes.func.isRequired,
+};
+
+export default TitleButton;
diff --git a/src/group-configurations/common/UsageList.jsx b/src/group-configurations/common/UsageList.jsx
new file mode 100644
index 0000000000..5c6287a9d6
--- /dev/null
+++ b/src/group-configurations/common/UsageList.jsx
@@ -0,0 +1,73 @@
+import PropTypes from 'prop-types';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Hyperlink, Stack, Icon } from '@openedx/paragon';
+import {
+ Warning as WarningIcon,
+ Error as ErrorIcon,
+} from '@openedx/paragon/icons';
+
+import { MESSAGE_VALIDATION_TYPES } from '../constants';
+import { formatUrlToUnitPage } from '../utils';
+import messages from './messages';
+
+const UsageList = ({ className, itemList, isExperiment }) => {
+ const { formatMessage } = useIntl();
+ const usageDescription = isExperiment
+ ? messages.experimentAccessTo
+ : messages.accessTo;
+
+ const renderValidationMessage = ({ text, type }) => (
+
+
+ {text}
+
+ );
+
+ return (
+
+
+ {formatMessage(usageDescription)}
+
+
+ {itemList.map(({ url, label, validation }) => (
+ <>
+
+ {label}
+
+ {validation && renderValidationMessage(validation)}
+ >
+ ))}
+
+
+ );
+};
+
+UsageList.defaultProps = {
+ className: undefined,
+ isExperiment: false,
+};
+
+UsageList.propTypes = {
+ className: PropTypes.string,
+ itemList: PropTypes.arrayOf(
+ PropTypes.shape({
+ label: PropTypes.string,
+ url: PropTypes.string,
+ validation: PropTypes.shape({
+ text: PropTypes.string,
+ type: PropTypes.string,
+ }),
+ }).isRequired,
+ ).isRequired,
+ isExperiment: PropTypes.bool,
+};
+
+export default UsageList;
diff --git a/src/group-configurations/common/UsageList.test.jsx b/src/group-configurations/common/UsageList.test.jsx
new file mode 100644
index 0000000000..e4d4681279
--- /dev/null
+++ b/src/group-configurations/common/UsageList.test.jsx
@@ -0,0 +1,34 @@
+import { render } from '@testing-library/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+
+import { contentGroupsMock } from '../__mocks__';
+import { formatUrlToUnitPage } from '../utils';
+import UsageList from './UsageList';
+import messages from './messages';
+
+const usages = contentGroupsMock.groups[1]?.usage;
+
+const renderComponent = (props = {}) => render(
+
+
+ ,
+);
+
+describe(' ', () => {
+ it('renders component correctly', () => {
+ const { getByText, getAllByRole } = renderComponent();
+ expect(getByText(messages.accessTo.defaultMessage)).toBeInTheDocument();
+ expect(getAllByRole('link')).toHaveLength(2);
+ getAllByRole('link').forEach((el, idx) => {
+ expect(el.href).toMatch(formatUrlToUnitPage(usages[idx].url));
+ expect(getByText(usages[idx].label)).toBeVisible();
+ });
+ });
+
+ it('renders experiment component correctly', () => {
+ const { getByText } = renderComponent({ isExperiment: true });
+ expect(
+ getByText(messages.experimentAccessTo.defaultMessage),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/group-configurations/common/index.js b/src/group-configurations/common/index.js
new file mode 100644
index 0000000000..0089b3865f
--- /dev/null
+++ b/src/group-configurations/common/index.js
@@ -0,0 +1,2 @@
+export { default as TitleButton } from './TitleButton';
+export { default as UsageList } from './UsageList';
diff --git a/src/group-configurations/common/messages.js b/src/group-configurations/common/messages.js
new file mode 100644
index 0000000000..708b376b08
--- /dev/null
+++ b/src/group-configurations/common/messages.js
@@ -0,0 +1,21 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ titleId: {
+ id: 'course-authoring.group-configurations.container.title-id',
+ defaultMessage: 'ID: {id}',
+ description: 'Message for the title of a container within group configurations section',
+ },
+ accessTo: {
+ id: 'course-authoring.group-configurations.container.access-to',
+ defaultMessage: 'This group controls access to:',
+ description: 'Indicates that the units are contained in content group',
+ },
+ experimentAccessTo: {
+ id: 'course-authoring.group-configurations.experiment-card.experiment-access-to',
+ defaultMessage: 'This group configuration is used in:',
+ description: 'Indicates that the units are contained in experiment configurations',
+ },
+});
+
+export default messages;
diff --git a/src/group-configurations/constants.js b/src/group-configurations/constants.js
new file mode 100644
index 0000000000..7e87fc8628
--- /dev/null
+++ b/src/group-configurations/constants.js
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+
+const availableGroupPropTypes = {
+ active: PropTypes.bool,
+ description: PropTypes.string,
+ groups: PropTypes.arrayOf(
+ PropTypes.shape({
+ id: PropTypes.number,
+ name: PropTypes.string,
+ usage: PropTypes.arrayOf(
+ PropTypes.shape({
+ label: PropTypes.string,
+ url: PropTypes.string,
+ }),
+ ),
+ version: PropTypes.number,
+ }),
+ ),
+ id: PropTypes.number,
+ name: PropTypes.string,
+ parameters: PropTypes.shape({
+ courseId: PropTypes.string,
+ }),
+ readOnly: PropTypes.bool,
+ scheme: PropTypes.string,
+ version: PropTypes.number,
+};
+
+const MESSAGE_VALIDATION_TYPES = {
+ error: 'error',
+ warning: 'warning',
+};
+
+export { MESSAGE_VALIDATION_TYPES, availableGroupPropTypes };
diff --git a/src/group-configurations/content-groups-section/ContentGroupCard.jsx b/src/group-configurations/content-groups-section/ContentGroupCard.jsx
new file mode 100644
index 0000000000..e56d4d4c4c
--- /dev/null
+++ b/src/group-configurations/content-groups-section/ContentGroupCard.jsx
@@ -0,0 +1,200 @@
+import { useState } from 'react';
+import PropTypes from 'prop-types';
+import { useParams } from 'react-router-dom';
+import { getConfig } from '@edx/frontend-platform';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import {
+ ActionRow,
+ Hyperlink,
+ Icon,
+ IconButtonWithTooltip,
+ useToggle,
+} from '@openedx/paragon';
+import {
+ DeleteOutline as DeleteOutlineIcon,
+ EditOutline as EditOutlineIcon,
+} from '@openedx/paragon/icons';
+
+import DeleteModal from '../../generic/delete-modal/DeleteModal';
+import TitleButton from '../common/TitleButton';
+import UsageList from '../common/UsageList';
+import ContentGroupForm from './ContentGroupForm';
+import messages from './messages';
+
+const ContentGroupCard = ({
+ group,
+ groupNames,
+ parentGroupId,
+ readOnly,
+ contentGroupActions,
+ handleEditGroup,
+}) => {
+ const { formatMessage } = useIntl();
+ const { courseId } = useParams();
+ const [isExpanded, setIsExpanded] = useState(false);
+ const [isEditMode, switchOnEditMode, switchOffEditMode] = useToggle(false);
+ const [isOpenDeleteModal, openDeleteModal, closeDeleteModal] = useToggle(false);
+ const { id, name, usage } = group;
+ const isUsedInLocation = !!usage.length;
+
+ const { href: outlineUrl } = new URL(
+ `/course/${courseId}`,
+ getConfig().STUDIO_BASE_URL,
+ );
+
+ const outlineComponentLink = (
+
+ {formatMessage(messages.courseOutline)}
+
+ );
+
+ const guideHowToAdd = (
+
+ {formatMessage(messages.emptyContentGroups, { outlineComponentLink })}
+
+ );
+
+ const handleExpandContent = () => {
+ setIsExpanded((prevState) => !prevState);
+ };
+
+ const handleDeleteGroup = () => {
+ contentGroupActions.handleDelete(parentGroupId, id);
+ closeDeleteModal();
+ };
+
+ return (
+ <>
+ {isEditMode ? (
+ handleEditGroup(id, values, switchOffEditMode)}
+ />
+ ) : (
+
+
+
+ {!readOnly && (
+
+
+
+
+ )}
+
+ {isExpanded && (
+
+ {usage?.length ? (
+
+ ) : (
+ guideHowToAdd
+ )}
+
+ )}
+
+ )}
+
+ >
+ );
+};
+
+ContentGroupCard.defaultProps = {
+ group: {
+ id: undefined,
+ name: '',
+ usage: [],
+ version: undefined,
+ },
+ readOnly: false,
+ groupNames: [],
+ parentGroupId: null,
+ handleEditGroup: null,
+ contentGroupActions: {},
+};
+
+ContentGroupCard.propTypes = {
+ group: PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ usage: PropTypes.arrayOf(
+ PropTypes.shape({
+ label: PropTypes.string,
+ url: PropTypes.string,
+ }),
+ ),
+ version: PropTypes.number.isRequired,
+ active: PropTypes.bool,
+ description: PropTypes.string,
+ groups: PropTypes.arrayOf(
+ PropTypes.shape({
+ id: PropTypes.number,
+ name: PropTypes.string,
+ usage: PropTypes.arrayOf(
+ PropTypes.shape({
+ label: PropTypes.string,
+ url: PropTypes.string,
+ }),
+ ),
+ version: PropTypes.number,
+ }),
+ ),
+ parameters: PropTypes.shape({
+ courseId: PropTypes.string,
+ }),
+ readOnly: PropTypes.bool,
+ scheme: PropTypes.string,
+ }),
+ groupNames: PropTypes.arrayOf(PropTypes.string),
+ parentGroupId: PropTypes.number,
+ readOnly: PropTypes.bool,
+ handleEditGroup: PropTypes.func,
+ contentGroupActions: PropTypes.shape({
+ handleCreate: PropTypes.func,
+ handleDelete: PropTypes.func,
+ handleEdit: PropTypes.func,
+ }),
+};
+
+export default ContentGroupCard;
diff --git a/src/group-configurations/content-groups-section/ContentGroupCard.test.jsx b/src/group-configurations/content-groups-section/ContentGroupCard.test.jsx
new file mode 100644
index 0000000000..10d259f0e3
--- /dev/null
+++ b/src/group-configurations/content-groups-section/ContentGroupCard.test.jsx
@@ -0,0 +1,93 @@
+import { render } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+
+import { contentGroupsMock } from '../__mocks__';
+import commonMessages from '../common/messages';
+import rootMessages from '../messages';
+import ContentGroupCard from './ContentGroupCard';
+
+const handleCreateMock = jest.fn();
+const handleDeleteMock = jest.fn();
+const handleEditMock = jest.fn();
+const contentGroupActions = {
+ handleCreate: handleCreateMock,
+ handleDelete: handleDeleteMock,
+ handleEdit: handleEditMock,
+};
+
+const handleEditGroupMock = jest.fn();
+const contentGroup = contentGroupsMock.groups[0];
+const contentGroupWithUsages = contentGroupsMock.groups[1];
+const contentGroupWithOnlyOneUsage = contentGroupsMock.groups[2];
+
+const renderComponent = (props = {}) => render(
+
+ group.name)}
+ parentGroupId={contentGroupsMock.id}
+ contentGroupActions={contentGroupActions}
+ handleEditGroup={handleEditGroupMock}
+ {...props}
+ />
+ ,
+);
+
+describe(' ', () => {
+ it('renders component correctly', () => {
+ const { getByText, getByTestId } = renderComponent();
+ expect(getByText(contentGroup.name)).toBeInTheDocument();
+ expect(
+ getByText(
+ commonMessages.titleId.defaultMessage.replace('{id}', contentGroup.id),
+ ),
+ ).toBeInTheDocument();
+ expect(getByText(rootMessages.notInUse.defaultMessage)).toBeInTheDocument();
+ expect(getByTestId('content-group-card-header-edit')).toBeInTheDocument();
+ expect(getByTestId('content-group-card-header-delete')).toBeInTheDocument();
+ });
+
+ it('expands/collapses the container group content on title click', () => {
+ const {
+ getByText, queryByTestId, getByTestId, queryByText,
+ } = renderComponent();
+ const cardTitle = getByTestId('configuration-card-header-button');
+ userEvent.click(cardTitle);
+ expect(queryByTestId('content-group-card-content')).toBeInTheDocument();
+ expect(
+ queryByText(rootMessages.notInUse.defaultMessage),
+ ).not.toBeInTheDocument();
+
+ userEvent.click(cardTitle);
+ expect(queryByTestId('content-group-card-content')).not.toBeInTheDocument();
+ expect(getByText(rootMessages.notInUse.defaultMessage)).toBeInTheDocument();
+ });
+
+ it('renders content group badge with used only one location', () => {
+ const { queryByTestId } = renderComponent({
+ group: contentGroupWithOnlyOneUsage,
+ });
+ const usageBlock = queryByTestId('configuration-card-header-button-usage');
+ expect(usageBlock).toBeInTheDocument();
+ });
+
+ it('renders content group badge with used locations', () => {
+ const { queryByTestId } = renderComponent({
+ group: contentGroupWithUsages,
+ });
+ const usageBlock = queryByTestId('configuration-card-header-button-usage');
+ expect(usageBlock).toBeInTheDocument();
+ });
+
+ it('renders group controls without access to units', () => {
+ const { queryByText, getByTestId } = renderComponent();
+ expect(
+ queryByText(commonMessages.accessTo.defaultMessage),
+ ).not.toBeInTheDocument();
+
+ const cardTitle = getByTestId('configuration-card-header-button');
+ userEvent.click(cardTitle);
+ expect(getByTestId('configuration-card-usage-empty')).toBeInTheDocument();
+ });
+});
diff --git a/src/group-configurations/content-groups-section/ContentGroupForm.jsx b/src/group-configurations/content-groups-section/ContentGroupForm.jsx
new file mode 100644
index 0000000000..b4f7a76ba2
--- /dev/null
+++ b/src/group-configurations/content-groups-section/ContentGroupForm.jsx
@@ -0,0 +1,123 @@
+import PropTypes from 'prop-types';
+import { Formik } from 'formik';
+import * as Yup from 'yup';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import {
+ Alert,
+ ActionRow,
+ Button,
+ Form,
+} from '@openedx/paragon';
+
+import { WarningFilled as WarningFilledIcon } from '@openedx/paragon/icons';
+
+import PromptIfDirty from '../../generic/prompt-if-dirty/PromptIfDirty';
+import { isAlreadyExistsGroup } from './utils';
+import messages from './messages';
+
+const ContentGroupForm = ({
+ isEditMode,
+ groupNames,
+ isUsedInLocation,
+ overrideValue,
+ onCreateClick,
+ onCancelClick,
+ onEditClick,
+}) => {
+ const { formatMessage } = useIntl();
+ const initialValues = { newGroupName: overrideValue };
+ const validationSchema = Yup.object().shape({
+ newGroupName: Yup.string()
+ .required(formatMessage(messages.requiredError))
+ .trim()
+ .test(
+ 'unique-name-restriction',
+ formatMessage(messages.invalidMessage),
+ (value) => overrideValue === value || !isAlreadyExistsGroup(groupNames, value),
+ ),
+ });
+ const onSubmitForm = isEditMode ? onEditClick : onCreateClick;
+
+ return (
+
+
+
{formatMessage(messages.newGroupHeader)}
+
+
+ {({
+ values, errors, dirty, handleChange, handleSubmit,
+ }) => {
+ const isInvalid = !!errors.newGroupName;
+
+ return (
+ <>
+
+
+ {isInvalid && (
+
+ {errors.newGroupName}
+
+ )}
+
+ {isUsedInLocation && (
+
+ {formatMessage(messages.alertGroupInUsage)}
+
+ )}
+
+
+ {formatMessage(messages.cancelButton)}
+
+
+ {formatMessage(
+ isEditMode ? messages.saveButton : messages.createButton,
+ )}
+
+
+
+ >
+ );
+ }}
+
+
+ );
+};
+
+ContentGroupForm.defaultProps = {
+ groupNames: [],
+ overrideValue: '',
+ isEditMode: false,
+ isUsedInLocation: false,
+ onCreateClick: null,
+ onEditClick: null,
+};
+
+ContentGroupForm.propTypes = {
+ groupNames: PropTypes.arrayOf(PropTypes.string),
+ isEditMode: PropTypes.bool,
+ isUsedInLocation: PropTypes.bool,
+ overrideValue: PropTypes.string,
+ onCreateClick: PropTypes.func,
+ onCancelClick: PropTypes.func.isRequired,
+ onEditClick: PropTypes.func,
+};
+
+export default ContentGroupForm;
diff --git a/src/group-configurations/content-groups-section/ContentGroupForm.test.jsx b/src/group-configurations/content-groups-section/ContentGroupForm.test.jsx
new file mode 100644
index 0000000000..22826daf63
--- /dev/null
+++ b/src/group-configurations/content-groups-section/ContentGroupForm.test.jsx
@@ -0,0 +1,176 @@
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import userEvent from '@testing-library/user-event';
+import { render, waitFor } from '@testing-library/react';
+
+import { contentGroupsMock } from '../__mocks__';
+import messages from './messages';
+import ContentGroupForm from './ContentGroupForm';
+
+const onCreateClickMock = jest.fn();
+const onCancelClickMock = jest.fn();
+const onEditClickMock = jest.fn();
+
+const renderComponent = (props = {}) => render(
+
+ group.name)}
+ onCreateClick={onCreateClickMock}
+ onCancelClick={onCancelClickMock}
+ onEditClick={onEditClickMock}
+ {...props}
+ />
+ ,
+);
+
+describe(' ', () => {
+ it('renders component correctly', () => {
+ const { getByText, getByRole, getByTestId } = renderComponent();
+
+ expect(getByTestId('content-group-form')).toBeInTheDocument();
+ expect(
+ getByText(messages.newGroupHeader.defaultMessage),
+ ).toBeInTheDocument();
+ expect(
+ getByRole('button', { name: messages.cancelButton.defaultMessage }),
+ ).toBeInTheDocument();
+ expect(
+ getByRole('button', { name: messages.createButton.defaultMessage }),
+ ).toBeInTheDocument();
+ });
+
+ it('renders component in edit mode', () => {
+ const {
+ getByText, queryByText, getByRole, getByPlaceholderText,
+ } = renderComponent({
+ isEditMode: true,
+ overrideValue: 'overrideValue',
+ });
+ const newGroupInput = getByPlaceholderText(
+ messages.newGroupInputPlaceholder.defaultMessage,
+ );
+
+ expect(newGroupInput).toBeInTheDocument();
+ expect(
+ getByText(messages.newGroupHeader.defaultMessage),
+ ).toBeInTheDocument();
+ expect(
+ getByRole('button', { name: messages.saveButton.defaultMessage }),
+ ).toBeInTheDocument();
+ expect(
+ queryByText(messages.alertGroupInUsage.defaultMessage),
+ ).not.toBeInTheDocument();
+ });
+
+ it('shows alert if group is used in location with edit mode', () => {
+ const { getByText } = renderComponent({
+ isEditMode: true,
+ overrideValue: 'overrideValue',
+ isUsedInLocation: true,
+ });
+ expect(
+ getByText(messages.alertGroupInUsage.defaultMessage),
+ ).toBeInTheDocument();
+ });
+
+ it('calls onCreate when the "Create" button is clicked with a valid form', async () => {
+ const {
+ getByRole, getByPlaceholderText, queryByText,
+ } = renderComponent();
+ const newGroupNameText = 'New group name';
+ const newGroupInput = getByPlaceholderText(
+ messages.newGroupInputPlaceholder.defaultMessage,
+ );
+ userEvent.type(newGroupInput, newGroupNameText);
+ const createButton = getByRole('button', {
+ name: messages.createButton.defaultMessage,
+ });
+ expect(createButton).toBeInTheDocument();
+ userEvent.click(createButton);
+
+ await waitFor(() => {
+ expect(onCreateClickMock).toHaveBeenCalledTimes(1);
+ });
+ expect(
+ queryByText(messages.requiredError.defaultMessage),
+ ).not.toBeInTheDocument();
+ });
+
+ it('shows error when the "Create" button is clicked with an invalid form', async () => {
+ const { getByRole, getByPlaceholderText, getByText } = renderComponent();
+ const newGroupNameText = '';
+ const newGroupInput = getByPlaceholderText(
+ messages.newGroupInputPlaceholder.defaultMessage,
+ );
+ userEvent.type(newGroupInput, newGroupNameText);
+ const createButton = getByRole('button', {
+ name: messages.createButton.defaultMessage,
+ });
+ expect(createButton).toBeInTheDocument();
+ userEvent.click(createButton);
+
+ await waitFor(() => {
+ expect(
+ getByText(messages.requiredError.defaultMessage),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('calls onEdit when the "Save" button is clicked with a valid form', async () => {
+ const { getByRole, getByPlaceholderText, queryByText } = renderComponent({
+ isEditMode: true,
+ overrideValue: 'overrideValue',
+ });
+ const newGroupNameText = 'Updated group name';
+ const newGroupInput = getByPlaceholderText(
+ messages.newGroupInputPlaceholder.defaultMessage,
+ );
+ userEvent.type(newGroupInput, newGroupNameText);
+ const saveButton = getByRole('button', {
+ name: messages.saveButton.defaultMessage,
+ });
+ expect(saveButton).toBeInTheDocument();
+ userEvent.click(saveButton);
+
+ await waitFor(() => {
+ expect(
+ queryByText(messages.requiredError.defaultMessage),
+ ).not.toBeInTheDocument();
+ });
+ expect(onEditClickMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows error when the "Save" button is clicked with an invalid duplicate form', async () => {
+ const { getByRole, getByPlaceholderText, getByText } = renderComponent({
+ isEditMode: true,
+ overrideValue: contentGroupsMock.groups[0].name,
+ });
+ const newGroupNameText = contentGroupsMock.groups[2].name;
+ const newGroupInput = getByPlaceholderText(
+ messages.newGroupInputPlaceholder.defaultMessage,
+ );
+ userEvent.clear(newGroupInput);
+ userEvent.type(newGroupInput, newGroupNameText);
+ const saveButton = getByRole('button', {
+ name: messages.saveButton.defaultMessage,
+ });
+ expect(saveButton).toBeInTheDocument();
+ userEvent.click(saveButton);
+
+ await waitFor(() => {
+ expect(
+ getByText(messages.invalidMessage.defaultMessage),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('calls onCancel when the "Cancel" button is clicked', async () => {
+ const { getByRole } = renderComponent();
+ const cancelButton = getByRole('button', {
+ name: messages.cancelButton.defaultMessage,
+ });
+ expect(cancelButton).toBeInTheDocument();
+ userEvent.click(cancelButton);
+
+ expect(onCancelClickMock).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/group-configurations/content-groups-section/ContentGroupsSection.test.jsx b/src/group-configurations/content-groups-section/ContentGroupsSection.test.jsx
new file mode 100644
index 0000000000..bbd9ca280f
--- /dev/null
+++ b/src/group-configurations/content-groups-section/ContentGroupsSection.test.jsx
@@ -0,0 +1,64 @@
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import userEvent from '@testing-library/user-event';
+import { render } from '@testing-library/react';
+
+import { contentGroupsMock } from '../__mocks__';
+import placeholderMessages from '../empty-placeholder/messages';
+import messages from './messages';
+import ContentGroupsSection from '.';
+
+const handleCreateMock = jest.fn();
+const handleDeleteMock = jest.fn();
+const handleEditMock = jest.fn();
+const contentGroupActions = {
+ handleCreate: handleCreateMock,
+ handleDelete: handleDeleteMock,
+ handleEdit: handleEditMock,
+};
+
+const renderComponent = (props = {}) => render(
+
+
+ ,
+);
+
+describe(' ', () => {
+ it('renders component correctly', () => {
+ const { getByText, getByRole, getAllByTestId } = renderComponent();
+ expect(getByText(contentGroupsMock.name)).toBeInTheDocument();
+ expect(
+ getByRole('button', { name: messages.addNewGroup.defaultMessage }),
+ ).toBeInTheDocument();
+
+ expect(getAllByTestId('content-group-card')).toHaveLength(
+ contentGroupsMock.groups.length,
+ );
+ });
+
+ it('renders empty section', () => {
+ const { getByTestId } = renderComponent({ availableGroup: {} });
+ expect(
+ getByTestId('group-configurations-empty-placeholder'),
+ ).toBeInTheDocument();
+ });
+
+ it('renders container with new group on create click if section is empty', async () => {
+ const { getByRole, getByTestId } = renderComponent({ availableGroup: {} });
+ userEvent.click(
+ getByRole('button', { name: placeholderMessages.button.defaultMessage }),
+ );
+ expect(getByTestId('content-group-form')).toBeInTheDocument();
+ });
+
+ it('renders container with new group on create click if section has groups', async () => {
+ const { getByRole, getByTestId } = renderComponent();
+ userEvent.click(
+ getByRole('button', { name: messages.addNewGroup.defaultMessage }),
+ );
+ expect(getByTestId('content-group-form')).toBeInTheDocument();
+ });
+});
diff --git a/src/group-configurations/content-groups-section/index.jsx b/src/group-configurations/content-groups-section/index.jsx
new file mode 100644
index 0000000000..de21e323f1
--- /dev/null
+++ b/src/group-configurations/content-groups-section/index.jsx
@@ -0,0 +1,95 @@
+import PropTypes from 'prop-types';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Button, useToggle } from '@openedx/paragon';
+import { Add as AddIcon } from '@openedx/paragon/icons';
+
+import { availableGroupPropTypes } from '../constants';
+import EmptyPlaceholder from '../empty-placeholder';
+import ContentGroupCard from './ContentGroupCard';
+import ContentGroupForm from './ContentGroupForm';
+import { initialContentGroupObject } from './utils';
+import messages from './messages';
+
+const ContentGroupsSection = ({
+ availableGroup,
+ contentGroupActions,
+}) => {
+ const { formatMessage } = useIntl();
+ const [isNewGroupVisible, openNewGroup, hideNewGroup] = useToggle(false);
+ const { id: parentGroupId, groups, name } = availableGroup;
+ const groupNames = groups?.map((group) => group.name);
+
+ const handleCreateNewGroup = (values) => {
+ const updatedContentGroups = {
+ ...availableGroup,
+ groups: [
+ ...availableGroup.groups,
+ initialContentGroupObject(values.newGroupName),
+ ],
+ };
+ contentGroupActions.handleCreate(updatedContentGroups, hideNewGroup);
+ };
+
+ const handleEditContentGroup = (id, { newGroupName }, callbackToClose) => {
+ const updatedContentGroups = {
+ ...availableGroup,
+ groups: availableGroup.groups.map((group) => (group.id === id ? { ...group, name: newGroupName } : group)),
+ };
+ contentGroupActions.handleEdit(updatedContentGroups, callbackToClose);
+ };
+
+ return (
+
+
+ {name}
+
+ {groups?.length ? (
+ <>
+ {groups.map((group) => (
+
+ ))}
+ {!isNewGroupVisible && (
+
+ {formatMessage(messages.addNewGroup)}
+
+ )}
+ >
+ ) : (
+ !isNewGroupVisible && (
+
+ )
+ )}
+ {isNewGroupVisible && (
+
+ )}
+
+ );
+};
+
+ContentGroupsSection.propTypes = {
+ availableGroup: PropTypes.shape(availableGroupPropTypes).isRequired,
+ contentGroupActions: PropTypes.shape({
+ handleCreate: PropTypes.func,
+ handleDelete: PropTypes.func,
+ handleEdit: PropTypes.func,
+ }).isRequired,
+};
+
+export default ContentGroupsSection;
diff --git a/src/group-configurations/content-groups-section/messages.js b/src/group-configurations/content-groups-section/messages.js
new file mode 100644
index 0000000000..834b847900
--- /dev/null
+++ b/src/group-configurations/content-groups-section/messages.js
@@ -0,0 +1,86 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ addNewGroup: {
+ id: 'course-authoring.group-configurations.content-groups.add-new-group',
+ defaultMessage: 'New content group',
+ description: 'Label for adding a new content group.',
+ },
+ newGroupHeader: {
+ id: 'course-authoring.group-configurations.content-groups.new-group.header',
+ defaultMessage: 'Content group name *',
+ description: 'Header text for the input field to enter the name of a new content group.',
+ },
+ newGroupInputPlaceholder: {
+ id: 'course-authoring.group-configurations.content-groups.new-group.input.placeholder',
+ defaultMessage: 'This is the name of the group',
+ description: 'Placeholder text for the input field where the name of a new content group is entered.',
+ },
+ invalidMessage: {
+ id: 'course-authoring.group-configurations.content-groups.new-group.invalid-message',
+ defaultMessage: 'All groups must have a unique name.',
+ description: 'Error message displayed when the name of the new content group is not unique.',
+ },
+ cancelButton: {
+ id: 'course-authoring.group-configurations.content-groups.new-group.cancel',
+ defaultMessage: 'Cancel',
+ description: 'Label for the cancel button when creating a new content group.',
+ },
+ deleteButton: {
+ id: 'course-authoring.group-configurations.content-groups.edit-group.delete',
+ defaultMessage: 'Delete',
+ description: 'Label for the delete button when editing a content group.',
+ },
+ createButton: {
+ id: 'course-authoring.group-configurations.content-groups.new-group.create',
+ defaultMessage: 'Create',
+ description: 'Label for the create button when creating a new content group.',
+ },
+ saveButton: {
+ id: 'course-authoring.group-configurations.content-groups.edit-group.save',
+ defaultMessage: 'Save',
+ description: 'Label for the save button when editing a content group.',
+ },
+ requiredError: {
+ id: 'course-authoring.group-configurations.content-groups.new-group.required-error',
+ defaultMessage: 'Group name is required',
+ description: 'Error message displayed when the name of the content group is required but not provided.',
+ },
+ alertGroupInUsage: {
+ id: 'course-authoring.group-configurations.content-groups.edit-group.alert-group-in-usage',
+ defaultMessage: 'This content group is used in one or more units.',
+ description: 'Alert message displayed when attempting to delete a content group that is currently in use by one or more units.',
+ },
+ deleteRestriction: {
+ id: 'course-authoring.group-configurations.content-groups.delete-restriction',
+ defaultMessage: 'Cannot delete when in use by a unit',
+ description: 'Message indicating that a content group cannot be deleted because it is currently in use by a unit.',
+ },
+ emptyContentGroups: {
+ id: 'course-authoring.group-configurations.container.empty-content-groups',
+ defaultMessage: 'In the {outlineComponentLink}, use this group to control access to a component.',
+ description: 'Message displayed when there are no content groups available, suggesting how to use them within the course outline.',
+ },
+ courseOutline: {
+ id: 'course-authoring.group-configurations.container.course-outline',
+ defaultMessage: 'Course outline',
+ description: 'Label for the course outline link.',
+ },
+ actionEdit: {
+ id: 'course-authoring.group-configurations.container.action.edit',
+ defaultMessage: 'Edit',
+ description: 'Label for the edit action in the container.',
+ },
+ actionDelete: {
+ id: 'course-authoring.group-configurations.container.action.delete',
+ defaultMessage: 'Delete',
+ description: 'Label for the delete action in the container.',
+ },
+ subtitleModalDelete: {
+ id: 'course-authoring.group-configurations.container.delete-modal.subtitle',
+ defaultMessage: 'content group',
+ description: 'Substr for the delete modal indicating the type of entity being deleted.',
+ },
+});
+
+export default messages;
diff --git a/src/group-configurations/content-groups-section/utils.js b/src/group-configurations/content-groups-section/utils.js
new file mode 100644
index 0000000000..eeecd16067
--- /dev/null
+++ b/src/group-configurations/content-groups-section/utils.js
@@ -0,0 +1,9 @@
+const isAlreadyExistsGroup = (groupNames, group) => groupNames.some((name) => name === group);
+
+const initialContentGroupObject = (groupName) => ({
+ name: groupName,
+ version: 1,
+ usage: [],
+});
+
+export { isAlreadyExistsGroup, initialContentGroupObject };
diff --git a/src/group-configurations/data/api.js b/src/group-configurations/data/api.js
new file mode 100644
index 0000000000..2c5aceb600
--- /dev/null
+++ b/src/group-configurations/data/api.js
@@ -0,0 +1,115 @@
+import { camelCaseObject, getConfig } from '@edx/frontend-platform';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+
+const API_PATH_PATTERN = 'group_configurations';
+const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL;
+
+export const getContentStoreApiUrl = (courseId) => `${getStudioBaseUrl()}/api/contentstore/v1/${API_PATH_PATTERN}/${courseId}`;
+export const getLegacyApiUrl = (courseId, parentGroupId, groupId) => {
+ const parentUrlPath = `${getStudioBaseUrl()}/${API_PATH_PATTERN}/${courseId}`;
+ const parentGroupPath = `${parentGroupId ? `/${parentGroupId}` : ''}`;
+ const groupPath = `${groupId ? `/${groupId}` : ''}`;
+ return `${parentUrlPath}${parentGroupPath}${groupPath}`;
+};
+
+/**
+ * Get content groups and experimental group configurations for course.
+ * @param {string} courseId
+ * @returns {Promise}
+ */
+export async function getGroupConfigurations(courseId) {
+ const { data } = await getAuthenticatedHttpClient().get(
+ getContentStoreApiUrl(courseId),
+ );
+
+ return camelCaseObject(data);
+}
+
+/**
+ * Create new content group for course.
+ * @param {string} courseId
+ * @param {object} group
+ * @returns {Promise}
+ */
+export async function createContentGroup(courseId, group) {
+ const { data } = await getAuthenticatedHttpClient().post(
+ getLegacyApiUrl(courseId, group.id),
+ group,
+ );
+
+ return camelCaseObject(data);
+}
+
+/**
+ * Edit exists content group in course.
+ * @param {string} courseId
+ * @param {object} group
+ * @returns {Promise}
+ */
+export async function editContentGroup(courseId, group) {
+ const { data } = await getAuthenticatedHttpClient().post(
+ getLegacyApiUrl(courseId, group.id),
+ group,
+ );
+
+ return camelCaseObject(data);
+}
+
+/**
+ * Delete exists content group from the course.
+ * @param {string} courseId
+ * @param {number} parentGroupId
+ * @param {number} groupId
+ * @returns {Promise}
+ */
+export async function deleteContentGroup(courseId, parentGroupId, groupId) {
+ const { data } = await getAuthenticatedHttpClient().delete(
+ getLegacyApiUrl(courseId, parentGroupId, groupId),
+ );
+
+ return camelCaseObject(data);
+}
+
+/**
+ * Create a new experiment configuration for the course.
+ * @param {string} courseId
+ * @param {object} configuration
+ * @returns {Promise}
+ */
+export async function createExperimentConfiguration(courseId, configuration) {
+ const { data } = await getAuthenticatedHttpClient().post(
+ getLegacyApiUrl(courseId),
+ configuration,
+ );
+
+ return camelCaseObject(data);
+}
+
+/**
+ * Edit the experiment configuration for the course.
+ * @param {string} courseId
+ * @param {object} configuration
+ * @returns {Promise}
+ */
+export async function editExperimentConfiguration(courseId, configuration) {
+ const { data } = await getAuthenticatedHttpClient().post(
+ getLegacyApiUrl(courseId, configuration.id),
+ configuration,
+ );
+
+ return camelCaseObject(data);
+}
+
+/**
+ * Delete existing experimental configuration from the course.
+ * @param {string} courseId
+ * @param {number} configurationId
+ * @returns {Promise}
+ */
+export async function deleteExperimentConfiguration(courseId, configurationId) {
+ const { data } = await getAuthenticatedHttpClient().delete(
+ getLegacyApiUrl(courseId, configurationId),
+ );
+
+ return camelCaseObject(data);
+}
diff --git a/src/group-configurations/data/api.test.js b/src/group-configurations/data/api.test.js
new file mode 100644
index 0000000000..fe5ef9fae4
--- /dev/null
+++ b/src/group-configurations/data/api.test.js
@@ -0,0 +1,149 @@
+import MockAdapter from 'axios-mock-adapter';
+import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+
+import { groupConfigurationResponseMock } from '../__mocks__';
+import { initialContentGroupObject } from '../content-groups-section/utils';
+import { initialExperimentConfiguration } from '../experiment-configurations-section/constants';
+import {
+ createContentGroup,
+ createExperimentConfiguration,
+ deleteContentGroup,
+ editContentGroup,
+ getContentStoreApiUrl,
+ getGroupConfigurations,
+ getLegacyApiUrl,
+} from './api';
+
+let axiosMock;
+const courseId = 'course-v1:org+101+101';
+const contentGroups = groupConfigurationResponseMock.allGroupConfigurations[1];
+const experimentConfigurations = groupConfigurationResponseMock.experimentGroupConfigurations;
+
+describe('group configurations API calls', () => {
+ beforeEach(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ },
+ });
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should fetch group configurations', async () => {
+ const response = { ...groupConfigurationResponseMock };
+ axiosMock.onGet(getContentStoreApiUrl(courseId)).reply(200, response);
+
+ const result = await getGroupConfigurations(courseId);
+ const expected = camelCaseObject(response);
+
+ expect(axiosMock.history.get[0].url).toEqual(
+ getContentStoreApiUrl(courseId),
+ );
+ expect(result).toEqual(expected);
+ });
+
+ it('should create content group', async () => {
+ const response = { ...groupConfigurationResponseMock };
+ const newContentGroupName = 'content-group-test';
+ const updatedContentGroups = {
+ ...contentGroups,
+ groups: [
+ ...contentGroups.groups,
+ initialContentGroupObject(newContentGroupName),
+ ],
+ };
+
+ response.allGroupConfigurations[1] = updatedContentGroups;
+ axiosMock
+ .onPost(getLegacyApiUrl(courseId, contentGroups.id), updatedContentGroups)
+ .reply(200, response);
+
+ const result = await createContentGroup(courseId, updatedContentGroups);
+ const expected = camelCaseObject(response);
+
+ expect(axiosMock.history.post[0].url).toEqual(
+ getLegacyApiUrl(courseId, updatedContentGroups.id),
+ );
+ expect(result).toEqual(expected);
+ });
+
+ it('should edit content group', async () => {
+ const editedName = 'content-group-edited';
+ const groupId = contentGroups.groups[0].id;
+ const response = { ...groupConfigurationResponseMock };
+ const editedContentGroups = {
+ ...contentGroups,
+ groups: contentGroups.groups.map((group) => (group.id === groupId ? { ...group, name: editedName } : group)),
+ };
+
+ response.allGroupConfigurations[1] = editedContentGroups;
+ axiosMock
+ .onPost(getLegacyApiUrl(courseId, contentGroups.id), editedContentGroups)
+ .reply(200, response);
+
+ const result = await editContentGroup(courseId, editedContentGroups);
+ const expected = camelCaseObject(response);
+
+ expect(axiosMock.history.post[0].url).toEqual(
+ getLegacyApiUrl(courseId, editedContentGroups.id),
+ );
+ expect(result).toEqual(expected);
+ });
+
+ it('should delete content group', async () => {
+ const parentGroupId = contentGroups.id;
+ const groupId = contentGroups.groups[0].id;
+ const response = { ...groupConfigurationResponseMock };
+ const updatedContentGroups = {
+ ...contentGroups,
+ groups: contentGroups.groups.filter((group) => group.id !== groupId),
+ };
+
+ response.allGroupConfigurations[1] = updatedContentGroups;
+ axiosMock
+ .onDelete(
+ getLegacyApiUrl(courseId, parentGroupId, groupId),
+ updatedContentGroups,
+ )
+ .reply(200, response);
+
+ const result = await deleteContentGroup(courseId, parentGroupId, groupId);
+ const expected = camelCaseObject(response);
+
+ expect(axiosMock.history.delete[0].url).toEqual(
+ getLegacyApiUrl(courseId, updatedContentGroups.id, groupId),
+ );
+ expect(result).toEqual(expected);
+ });
+
+ it('should create experiment configurations', async () => {
+ const newConfigurationName = 'experiment-configuration-test';
+ const response = { ...groupConfigurationResponseMock };
+ const updatedConfigurations = [
+ ...experimentConfigurations,
+ { ...initialExperimentConfiguration, name: newConfigurationName },
+ ];
+
+ response.experimentGroupConfigurations = updatedConfigurations;
+ axiosMock
+ .onPost(getLegacyApiUrl(courseId), updatedConfigurations)
+ .reply(200, response);
+
+ const result = await createExperimentConfiguration(
+ courseId,
+ updatedConfigurations,
+ );
+ const expected = camelCaseObject(response);
+
+ expect(axiosMock.history.post[0].url).toEqual(getLegacyApiUrl(courseId));
+ expect(result).toEqual(expected);
+ });
+});
diff --git a/src/group-configurations/data/selectors.js b/src/group-configurations/data/selectors.js
new file mode 100644
index 0000000000..7f3f0d230d
--- /dev/null
+++ b/src/group-configurations/data/selectors.js
@@ -0,0 +1,3 @@
+export const getGroupConfigurationsData = (state) => state.groupConfigurations.groupConfigurations;
+export const getLoadingStatus = (state) => state.groupConfigurations.loadingStatus;
+export const getSavingStatus = (state) => state.groupConfigurations.savingStatus;
diff --git a/src/group-configurations/data/slice.js b/src/group-configurations/data/slice.js
new file mode 100644
index 0000000000..4530de1943
--- /dev/null
+++ b/src/group-configurations/data/slice.js
@@ -0,0 +1,78 @@
+/* eslint-disable no-param-reassign */
+import { createSlice } from '@reduxjs/toolkit';
+
+import { RequestStatus } from '../../data/constants';
+
+const slice = createSlice({
+ name: 'groupConfigurations',
+ initialState: {
+ savingStatus: '',
+ loadingStatus: RequestStatus.IN_PROGRESS,
+ groupConfigurations: {},
+ },
+ reducers: {
+ fetchGroupConfigurations: (state, { payload }) => {
+ state.groupConfigurations = payload.groupConfigurations;
+ },
+ updateGroupConfigurationsSuccess: (state, { payload }) => {
+ const groupIndex = state.groupConfigurations.allGroupConfigurations.findIndex(
+ group => payload.data.id === group.id,
+ );
+
+ if (groupIndex !== -1) {
+ state.groupConfigurations.allGroupConfigurations[groupIndex] = payload.data;
+ }
+ },
+ deleteGroupConfigurationsSuccess: (state, { payload }) => {
+ const { parentGroupId, groupId } = payload;
+ const parentGroupIndex = state.groupConfigurations.allGroupConfigurations.findIndex(
+ group => parentGroupId === group.id,
+ );
+ if (parentGroupIndex !== -1) {
+ state.groupConfigurations.allGroupConfigurations[parentGroupIndex].groups = state
+ .groupConfigurations.allGroupConfigurations[parentGroupIndex].groups.filter(group => group.id !== groupId);
+ }
+ },
+ updateLoadingStatus: (state, { payload }) => {
+ state.loadingStatus = payload.status;
+ },
+ updateSavingStatuses: (state, { payload }) => {
+ state.savingStatus = payload.status;
+ },
+ updateExperimentConfigurationSuccess: (state, { payload }) => {
+ const { configuration } = payload;
+ const experimentConfigurationState = state.groupConfigurations.experimentGroupConfigurations;
+ const configurationIdx = experimentConfigurationState.findIndex(
+ (conf) => configuration.id === conf.id,
+ );
+
+ if (configurationIdx !== -1) {
+ experimentConfigurationState[configurationIdx] = configuration;
+ } else {
+ state.groupConfigurations.experimentGroupConfigurations = [
+ ...experimentConfigurationState,
+ configuration,
+ ];
+ }
+ },
+ deleteExperimentConfigurationSuccess: (state, { payload }) => {
+ const { configurationId } = payload;
+ const filteredGroups = state.groupConfigurations.experimentGroupConfigurations.filter(
+ (configuration) => configuration.id !== configurationId,
+ );
+ state.groupConfigurations.experimentGroupConfigurations = filteredGroups;
+ },
+ },
+});
+
+export const {
+ fetchGroupConfigurations,
+ updateLoadingStatus,
+ updateSavingStatuses,
+ updateGroupConfigurationsSuccess,
+ deleteGroupConfigurationsSuccess,
+ updateExperimentConfigurationSuccess,
+ deleteExperimentConfigurationSuccess,
+} = slice.actions;
+
+export const { reducer } = slice;
diff --git a/src/group-configurations/data/slice.test.js b/src/group-configurations/data/slice.test.js
new file mode 100644
index 0000000000..ebc6ef8780
--- /dev/null
+++ b/src/group-configurations/data/slice.test.js
@@ -0,0 +1,78 @@
+import {
+ reducer,
+ fetchGroupConfigurations,
+ updateGroupConfigurationsSuccess,
+ deleteGroupConfigurationsSuccess,
+ updateExperimentConfigurationSuccess,
+ deleteExperimentConfigurationSuccess,
+} from './slice';
+import { RequestStatus } from '../../data/constants';
+
+describe('groupConfigurations slice', () => {
+ let initialState;
+
+ beforeEach(() => {
+ initialState = {
+ savingStatus: '',
+ loadingStatus: RequestStatus.IN_PROGRESS,
+ groupConfigurations: {
+ allGroupConfigurations: [{ id: 1, name: 'Group 1', groups: [{ id: 1, name: 'inner group' }] }],
+ experimentGroupConfigurations: [],
+ },
+ };
+ });
+
+ it('should update group configurations with fetchGroupConfigurations', () => {
+ const payload = {
+ groupConfigurations: {
+ allGroupConfigurations: [{ id: 2, name: 'Group 2' }],
+ experimentGroupConfigurations: [],
+ },
+ };
+
+ const newState = reducer(initialState, fetchGroupConfigurations(payload));
+
+ expect(newState.groupConfigurations).toEqual(payload.groupConfigurations);
+ });
+
+ it('should update an existing group configuration with updateGroupConfigurationsSuccess', () => {
+ const payload = { data: { id: 1, name: 'Updated Group' } };
+
+ const newState = reducer(initialState, updateGroupConfigurationsSuccess(payload));
+
+ expect(newState.groupConfigurations.allGroupConfigurations[0]).toEqual(payload.data);
+ });
+
+ it('should delete a group configuration with deleteGroupConfigurationsSuccess', () => {
+ const payload = { parentGroupId: 1, groupId: 1 };
+
+ const newState = reducer(initialState, deleteGroupConfigurationsSuccess(payload));
+
+ expect(newState.groupConfigurations.allGroupConfigurations[0].groups.length).toEqual(0);
+ });
+
+ it('should update experiment configuration with updateExperimentConfigurationSuccess', () => {
+ const payload = { configuration: { id: 1, name: 'Experiment Config' } };
+
+ const newState = reducer(initialState, updateExperimentConfigurationSuccess(payload));
+
+ expect(newState.groupConfigurations.experimentGroupConfigurations.length).toEqual(1);
+ expect(newState.groupConfigurations.experimentGroupConfigurations[0]).toEqual(payload.configuration);
+ });
+
+ it('should delete an experiment configuration with deleteExperimentConfigurationSuccess', () => {
+ const initialStateWithExperiment = {
+ savingStatus: '',
+ loadingStatus: RequestStatus.IN_PROGRESS,
+ groupConfigurations: {
+ allGroupConfigurations: [],
+ experimentGroupConfigurations: [{ id: 1, name: 'Experiment Config' }],
+ },
+ };
+ const payload = { configurationId: 1 };
+
+ const newState = reducer(initialStateWithExperiment, deleteExperimentConfigurationSuccess(payload));
+
+ expect(newState.groupConfigurations.experimentGroupConfigurations.length).toEqual(0);
+ });
+});
diff --git a/src/group-configurations/data/thunk.js b/src/group-configurations/data/thunk.js
new file mode 100644
index 0000000000..019a222ad7
--- /dev/null
+++ b/src/group-configurations/data/thunk.js
@@ -0,0 +1,148 @@
+import { RequestStatus } from '../../data/constants';
+import { NOTIFICATION_MESSAGES } from '../../constants';
+import {
+ hideProcessingNotification,
+ showProcessingNotification,
+} from '../../generic/processing-notification/data/slice';
+import {
+ getGroupConfigurations,
+ createContentGroup,
+ editContentGroup,
+ deleteContentGroup,
+ createExperimentConfiguration,
+ editExperimentConfiguration,
+ deleteExperimentConfiguration,
+} from './api';
+import {
+ fetchGroupConfigurations,
+ updateLoadingStatus,
+ updateSavingStatuses,
+ updateGroupConfigurationsSuccess,
+ deleteGroupConfigurationsSuccess,
+ updateExperimentConfigurationSuccess,
+ deleteExperimentConfigurationSuccess,
+} from './slice';
+
+export function fetchGroupConfigurationsQuery(courseId) {
+ return async (dispatch) => {
+ dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
+
+ try {
+ const groupConfigurations = await getGroupConfigurations(courseId);
+ dispatch(fetchGroupConfigurations({ groupConfigurations }));
+ dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
+ } catch (error) {
+ dispatch(updateLoadingStatus({ status: RequestStatus.FAILED }));
+ }
+ };
+}
+
+export function createContentGroupQuery(courseId, group) {
+ return async (dispatch) => {
+ dispatch(updateSavingStatuses({ status: RequestStatus.PENDING }));
+ dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
+
+ try {
+ const data = await createContentGroup(courseId, group);
+ dispatch(updateGroupConfigurationsSuccess({ data }));
+ dispatch(updateSavingStatuses({ status: RequestStatus.SUCCESSFUL }));
+ return true;
+ } catch (error) {
+ dispatch(updateSavingStatuses({ status: RequestStatus.FAILED }));
+ return false;
+ } finally {
+ dispatch(hideProcessingNotification());
+ }
+ };
+}
+
+export function editContentGroupQuery(courseId, group) {
+ return async (dispatch) => {
+ dispatch(updateSavingStatuses({ status: RequestStatus.PENDING }));
+ dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
+
+ try {
+ const data = await editContentGroup(courseId, group);
+ dispatch(updateGroupConfigurationsSuccess({ data }));
+ dispatch(updateSavingStatuses({ status: RequestStatus.SUCCESSFUL }));
+ return true;
+ } catch (error) {
+ dispatch(updateSavingStatuses({ status: RequestStatus.FAILED }));
+ return false;
+ } finally {
+ dispatch(hideProcessingNotification());
+ }
+ };
+}
+
+export function deleteContentGroupQuery(courseId, parentGroupId, groupId) {
+ return async (dispatch) => {
+ dispatch(updateSavingStatuses({ status: RequestStatus.PENDING }));
+ dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting));
+
+ try {
+ await deleteContentGroup(courseId, parentGroupId, groupId);
+ dispatch(deleteGroupConfigurationsSuccess({ parentGroupId, groupId }));
+ dispatch(updateSavingStatuses({ status: RequestStatus.SUCCESSFUL }));
+ } catch (error) {
+ dispatch(updateSavingStatuses({ status: RequestStatus.FAILED }));
+ } finally {
+ dispatch(hideProcessingNotification());
+ }
+ };
+}
+
+export function createExperimentConfigurationQuery(courseId, newConfiguration) {
+ return async (dispatch) => {
+ dispatch(updateSavingStatuses({ status: RequestStatus.PENDING }));
+ dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
+
+ try {
+ const configuration = await createExperimentConfiguration(courseId, newConfiguration);
+ dispatch(updateExperimentConfigurationSuccess({ configuration }));
+ dispatch(updateSavingStatuses({ status: RequestStatus.SUCCESSFUL }));
+ return true;
+ } catch (error) {
+ dispatch(updateSavingStatuses({ status: RequestStatus.FAILED }));
+ return false;
+ } finally {
+ dispatch(hideProcessingNotification());
+ }
+ };
+}
+
+export function editExperimentConfigurationQuery(courseId, editedConfiguration) {
+ return async (dispatch) => {
+ dispatch(updateSavingStatuses({ status: RequestStatus.PENDING }));
+ dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
+
+ try {
+ const configuration = await editExperimentConfiguration(courseId, editedConfiguration);
+ dispatch(updateExperimentConfigurationSuccess({ configuration }));
+ dispatch(updateSavingStatuses({ status: RequestStatus.SUCCESSFUL }));
+ return true;
+ } catch (error) {
+ dispatch(updateSavingStatuses({ status: RequestStatus.FAILED }));
+ return false;
+ } finally {
+ dispatch(hideProcessingNotification());
+ }
+ };
+}
+
+export function deleteExperimentConfigurationQuery(courseId, configurationId) {
+ return async (dispatch) => {
+ dispatch(updateSavingStatuses({ status: RequestStatus.PENDING }));
+ dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting));
+
+ try {
+ await deleteExperimentConfiguration(courseId, configurationId);
+ dispatch(deleteExperimentConfigurationSuccess({ configurationId }));
+ dispatch(updateSavingStatuses({ status: RequestStatus.SUCCESSFUL }));
+ } catch (error) {
+ dispatch(updateSavingStatuses({ status: RequestStatus.FAILED }));
+ } finally {
+ dispatch(hideProcessingNotification());
+ }
+ };
+}
diff --git a/src/group-configurations/data/thunk.test.js b/src/group-configurations/data/thunk.test.js
new file mode 100644
index 0000000000..131acec3d1
--- /dev/null
+++ b/src/group-configurations/data/thunk.test.js
@@ -0,0 +1,95 @@
+import MockAdapter from 'axios-mock-adapter';
+import { initializeMockApp } from '@edx/frontend-platform';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+
+import { groupConfigurationResponseMock } from '../__mocks__';
+import { getContentStoreApiUrl, getLegacyApiUrl } from './api';
+import * as thunkActions from './thunk';
+import initializeStore from '../../store';
+import { executeThunk } from '../../utils';
+
+let axiosMock;
+let store;
+const courseId = 'course-v1:org+101+101';
+
+describe('group configurations thunk', () => {
+ beforeEach(async () => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ },
+ });
+ store = initializeStore();
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ const response = { ...groupConfigurationResponseMock };
+ axiosMock.onGet(getContentStoreApiUrl(courseId)).reply(200, response);
+ await executeThunk(thunkActions.fetchGroupConfigurationsQuery(courseId), store.dispatch);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('dispatches correct actions on createContentGroupQuery', async () => {
+ const mockResponse = { id: 50, name: 'new' };
+ axiosMock.onPost(getLegacyApiUrl(courseId)).reply(200, mockResponse);
+
+ await executeThunk(thunkActions.createContentGroupQuery(courseId, {}), store.dispatch);
+ const updatedGroup = store.getState()
+ .groupConfigurations.groupConfigurations.allGroupConfigurations
+ .find(group => group.id === mockResponse.id);
+ expect(updatedGroup.name).toEqual(mockResponse.name);
+ });
+ it('dispatches correct actions on editContentGroupQuery', async () => {
+ const mockResponse = { id: 50, name: 'new' };
+ axiosMock.onPost(getLegacyApiUrl(courseId)).reply(200, mockResponse);
+
+ await executeThunk(thunkActions.editContentGroupQuery(courseId, {}), store.dispatch);
+ const updatedGroup = store.getState()
+ .groupConfigurations.groupConfigurations.allGroupConfigurations
+ .find(group => group.id === mockResponse.id);
+ expect(updatedGroup.name).toEqual(mockResponse.name);
+ });
+ it('dispatches correct actions on createExperimentConfigurationQuery', async () => {
+ const mockResponse = { id: 50, name: 'new' };
+ axiosMock.onPost(getLegacyApiUrl(courseId)).reply(200, mockResponse);
+
+ await executeThunk(thunkActions.createExperimentConfigurationQuery(courseId, {}), store.dispatch);
+ const updatedGroup = store.getState()
+ .groupConfigurations.groupConfigurations.experimentGroupConfigurations
+ .find(group => group.id === mockResponse.id);
+ expect(updatedGroup.name).toEqual(mockResponse.name);
+ });
+ it('dispatches correct actions on editExperimentConfigurationQuery', async () => {
+ const mockResponse = { id: 50, name: 'new' };
+ axiosMock.onPost(getLegacyApiUrl(courseId)).reply(200, mockResponse);
+
+ await executeThunk(thunkActions.editExperimentConfigurationQuery(courseId, {}), store.dispatch);
+ const updatedGroup = store.getState()
+ .groupConfigurations.groupConfigurations.experimentGroupConfigurations
+ .find(group => group.id === mockResponse.id);
+ expect(updatedGroup.name).toEqual(mockResponse.name);
+ });
+ it('dispatches correct actions on deleteContentGroupQuery', async () => {
+ const groupToDelete = { id: 6, name: 'deleted' };
+ axiosMock.onDelete(getLegacyApiUrl(courseId)).reply(200, {});
+
+ await executeThunk(thunkActions.deleteContentGroupQuery(courseId, groupToDelete.id), store.dispatch);
+ const updatedGroup = store.getState()
+ .groupConfigurations.groupConfigurations.allGroupConfigurations
+ .find(group => group.id === groupToDelete.id);
+ expect(updatedGroup).toBeFalsy();
+ });
+ it('dispatches correct actions on deleteExperimentConfigurationQuery', async () => {
+ const groupToDelete = { id: 276408623, name: 'deleted' };
+ axiosMock.onDelete(getLegacyApiUrl(courseId)).reply(200, {});
+ await executeThunk(thunkActions.deleteExperimentConfigurationQuery(courseId, groupToDelete.id), store.dispatch);
+ const updatedGroup = store.getState()
+ .groupConfigurations.groupConfigurations.experimentGroupConfigurations
+ .find(group => group.id === groupToDelete.id);
+ expect(updatedGroup).toBeFalsy();
+ });
+});
diff --git a/src/group-configurations/empty-placeholder/EmptyPlaceholder.scss b/src/group-configurations/empty-placeholder/EmptyPlaceholder.scss
new file mode 100644
index 0000000000..1768ecac81
--- /dev/null
+++ b/src/group-configurations/empty-placeholder/EmptyPlaceholder.scss
@@ -0,0 +1,10 @@
+.group-configurations-empty-placeholder {
+ @include pgn-box-shadow(1, "down");
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 1.5rem;
+ border-radius: .375rem;
+ padding: 1.5rem;
+}
diff --git a/src/group-configurations/empty-placeholder/EmptyPlaceholder.test.jsx b/src/group-configurations/empty-placeholder/EmptyPlaceholder.test.jsx
new file mode 100644
index 0000000000..5d4ed0b5cb
--- /dev/null
+++ b/src/group-configurations/empty-placeholder/EmptyPlaceholder.test.jsx
@@ -0,0 +1,22 @@
+import { render } from '@testing-library/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+
+import messages from './messages';
+import EmptyPlaceholder from '.';
+
+const onCreateNewGroup = jest.fn();
+
+const renderComponent = () => render(
+
+
+ ,
+);
+
+describe(' ', () => {
+ it('renders EmptyPlaceholder component correctly', () => {
+ const { getByText, getByRole } = renderComponent();
+
+ expect(getByText(messages.title.defaultMessage)).toBeInTheDocument();
+ expect(getByRole('button', { name: messages.button.defaultMessage })).toBeInTheDocument();
+ });
+});
diff --git a/src/group-configurations/empty-placeholder/index.jsx b/src/group-configurations/empty-placeholder/index.jsx
new file mode 100644
index 0000000000..80b780d1d5
--- /dev/null
+++ b/src/group-configurations/empty-placeholder/index.jsx
@@ -0,0 +1,42 @@
+import PropTypes from 'prop-types';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Add as IconAdd } from '@openedx/paragon/icons';
+import { Button } from '@openedx/paragon';
+
+import messages from './messages';
+
+const EmptyPlaceholder = ({ onCreateNewGroup, isExperiment }) => {
+ const { formatMessage } = useIntl();
+ const titleMessage = isExperiment
+ ? messages.experimentalTitle
+ : messages.title;
+ const buttonMessage = isExperiment
+ ? messages.experimentalButton
+ : messages.button;
+
+ return (
+
+
{formatMessage(titleMessage)}
+
+ {formatMessage(buttonMessage)}
+
+
+ );
+};
+
+EmptyPlaceholder.defaultProps = {
+ isExperiment: false,
+};
+
+EmptyPlaceholder.propTypes = {
+ onCreateNewGroup: PropTypes.func.isRequired,
+ isExperiment: PropTypes.bool,
+};
+
+export default EmptyPlaceholder;
diff --git a/src/group-configurations/empty-placeholder/messages.js b/src/group-configurations/empty-placeholder/messages.js
new file mode 100644
index 0000000000..29fcf3b2cb
--- /dev/null
+++ b/src/group-configurations/empty-placeholder/messages.js
@@ -0,0 +1,26 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ title: {
+ id: 'course-authoring.group-configurations.empty-placeholder.title',
+ defaultMessage: 'You have not created any content groups yet.',
+ description: 'Title displayed when there are no content groups created yet.',
+ },
+ experimentalTitle: {
+ id: 'course-authoring.group-configurations.experimental-empty-placeholder.title',
+ defaultMessage: 'You have not created any group configurations yet.',
+ description: 'Title displayed when there are no experimental group configurations created yet.',
+ },
+ button: {
+ id: 'course-authoring.group-configurations.empty-placeholder.button',
+ defaultMessage: 'Add your first content group',
+ description: 'Label for the button to add the first content group when none exist.',
+ },
+ experimentalButton: {
+ id: 'course-authoring.group-configurations.experimental-empty-placeholder.button',
+ defaultMessage: 'Add your first group configuration',
+ description: 'Label for the button to add the first experimental group configuration when none exist.',
+ },
+});
+
+export default messages;
diff --git a/src/group-configurations/enrollment-track-groups-section/EnrollmentTrackGroupsSection.test.jsx b/src/group-configurations/enrollment-track-groups-section/EnrollmentTrackGroupsSection.test.jsx
new file mode 100644
index 0000000000..84283ab689
--- /dev/null
+++ b/src/group-configurations/enrollment-track-groups-section/EnrollmentTrackGroupsSection.test.jsx
@@ -0,0 +1,24 @@
+import { render } from '@testing-library/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+
+import { enrollmentTrackGroupsMock } from '../__mocks__';
+import EnrollmentTrackGroupsSection from '.';
+
+const renderComponent = (props = {}) => render(
+
+
+ ,
+);
+
+describe(' ', () => {
+ it('renders component correctly', () => {
+ const { getByText, getAllByTestId } = renderComponent();
+ expect(getByText(enrollmentTrackGroupsMock.name)).toBeInTheDocument();
+ expect(getAllByTestId('content-group-card')).toHaveLength(
+ enrollmentTrackGroupsMock.groups.length,
+ );
+ });
+});
diff --git a/src/group-configurations/enrollment-track-groups-section/index.jsx b/src/group-configurations/enrollment-track-groups-section/index.jsx
new file mode 100644
index 0000000000..3456a926c6
--- /dev/null
+++ b/src/group-configurations/enrollment-track-groups-section/index.jsx
@@ -0,0 +1,23 @@
+import PropTypes from 'prop-types';
+
+import { availableGroupPropTypes } from '../constants';
+import ContentGroupCard from '../content-groups-section/ContentGroupCard';
+
+const EnrollmentTrackGroupsSection = ({ availableGroup: { groups, name } }) => (
+
+
{name}
+ {groups.map((group) => (
+
+ ))}
+
+);
+
+EnrollmentTrackGroupsSection.propTypes = {
+ availableGroup: PropTypes.shape(availableGroupPropTypes).isRequired,
+};
+
+export default EnrollmentTrackGroupsSection;
diff --git a/src/group-configurations/experiment-configurations-section/ExperimentCard.jsx b/src/group-configurations/experiment-configurations-section/ExperimentCard.jsx
new file mode 100644
index 0000000000..60a6d177d2
--- /dev/null
+++ b/src/group-configurations/experiment-configurations-section/ExperimentCard.jsx
@@ -0,0 +1,221 @@
+import { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import { useParams } from 'react-router-dom';
+import { getConfig } from '@edx/frontend-platform';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import {
+ ActionRow,
+ Hyperlink,
+ Icon,
+ IconButtonWithTooltip,
+ useToggle,
+} from '@openedx/paragon';
+import {
+ DeleteOutline as DeleteOutlineIcon,
+ EditOutline as EditOutlineIcon,
+} from '@openedx/paragon/icons';
+
+import DeleteModal from '../../generic/delete-modal/DeleteModal';
+import TitleButton from '../common/TitleButton';
+import UsageList from '../common/UsageList';
+import ExperimentCardGroup from './ExperimentCardGroup';
+import ExperimentForm from './ExperimentForm';
+import messages from './messages';
+import { initialExperimentConfiguration } from './constants';
+
+const ExperimentCard = ({
+ configuration,
+ experimentConfigurationActions,
+ isExpandedByDefault,
+ onCreate,
+}) => {
+ const { formatMessage } = useIntl();
+ const { courseId } = useParams();
+ const [isExpanded, setIsExpanded] = useState(false);
+ const [isEditMode, switchOnEditMode, switchOffEditMode] = useToggle(false);
+ const [isOpenDeleteModal, openDeleteModal, closeDeleteModal] = useToggle(false);
+
+ useEffect(() => {
+ setIsExpanded(isExpandedByDefault);
+ }, [isExpandedByDefault]);
+
+ const {
+ id, groups: groupsControl, description, usage,
+ } = configuration;
+ const isUsedInLocation = !!usage?.length;
+
+ const { href: outlineUrl } = new URL(
+ `/course/${courseId}`,
+ getConfig().STUDIO_BASE_URL,
+ );
+
+ const outlineComponentLink = (
+
+ {formatMessage(messages.courseOutline)}
+
+ );
+
+ const guideHowToAdd = (
+
+ {formatMessage(messages.emptyExperimentGroup, { outlineComponentLink })}
+
+ );
+
+ // We need to store actual idx as an additional field for getNextGroupName utility.
+ const configurationGroupsWithIndexField = {
+ ...configuration,
+ groups: configuration.groups.map((group, idx) => ({ ...group, idx })),
+ };
+
+ const formValues = isEditMode
+ ? configurationGroupsWithIndexField
+ : initialExperimentConfiguration;
+
+ const handleDeleteConfiguration = () => {
+ experimentConfigurationActions.handleDelete(id);
+ closeDeleteModal();
+ };
+
+ const handleEditConfiguration = (values) => {
+ experimentConfigurationActions.handleEdit(values, switchOffEditMode);
+ };
+
+ return (
+ <>
+ {isEditMode ? (
+
+ ) : (
+
+
+
setIsExpanded((prevState) => !prevState)}
+ isExperiment
+ />
+
+
+
+
+
+ {isExpanded && (
+
+ {description}
+
+ {usage?.length ? (
+
+ ) : (
+ guideHowToAdd
+ )}
+
+ )}
+
+ )}
+
+ >
+ );
+};
+
+ExperimentCard.defaultProps = {
+ configuration: {
+ id: undefined,
+ name: '',
+ usage: [],
+ version: undefined,
+ },
+ isExpandedByDefault: false,
+ onCreate: null,
+ experimentConfigurationActions: {},
+};
+
+ExperimentCard.propTypes = {
+ configuration: PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ usage: PropTypes.arrayOf(
+ PropTypes.shape({
+ label: PropTypes.string,
+ url: PropTypes.string,
+ validation: PropTypes.shape({
+ type: PropTypes.string,
+ text: PropTypes.string,
+ }),
+ }),
+ ),
+ version: PropTypes.number.isRequired,
+ active: PropTypes.bool,
+ description: PropTypes.string,
+ groups: PropTypes.arrayOf(
+ PropTypes.shape({
+ id: PropTypes.number,
+ name: PropTypes.string,
+ usage: PropTypes.arrayOf(
+ PropTypes.shape({
+ label: PropTypes.string,
+ url: PropTypes.string,
+ }),
+ ),
+ version: PropTypes.number,
+ }),
+ ),
+ parameters: PropTypes.shape({
+ courseId: PropTypes.string,
+ }),
+ scheme: PropTypes.string,
+ }),
+ isExpandedByDefault: PropTypes.bool,
+ onCreate: PropTypes.func,
+ experimentConfigurationActions: PropTypes.shape({
+ handleCreate: PropTypes.func,
+ handleEdit: PropTypes.func,
+ handleDelete: PropTypes.func,
+ }),
+};
+
+export default ExperimentCard;
diff --git a/src/group-configurations/experiment-configurations-section/ExperimentCard.test.jsx b/src/group-configurations/experiment-configurations-section/ExperimentCard.test.jsx
new file mode 100644
index 0000000000..60c47fc390
--- /dev/null
+++ b/src/group-configurations/experiment-configurations-section/ExperimentCard.test.jsx
@@ -0,0 +1,123 @@
+import { render } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+
+import { experimentGroupConfigurationsMock } from '../__mocks__';
+import commonMessages from '../common/messages';
+import ExperimentCard from './ExperimentCard';
+
+const handleCreateMock = jest.fn();
+const handleDeleteMock = jest.fn();
+const handleEditMock = jest.fn();
+const experimentConfigurationActions = {
+ handleCreate: handleCreateMock,
+ handleDelete: handleDeleteMock,
+ handleEdit: handleEditMock,
+};
+
+const onCreateMock = jest.fn();
+const experimentConfiguration = experimentGroupConfigurationsMock[0];
+
+const renderComponent = (props = {}) => render(
+
+
+ ,
+);
+
+describe(' ', () => {
+ it('renders component correctly', () => {
+ const { getByText, getByTestId } = renderComponent();
+ expect(getByText(experimentConfiguration.name)).toBeInTheDocument();
+ expect(
+ getByText(
+ commonMessages.titleId.defaultMessage.replace(
+ '{id}',
+ experimentConfiguration.id,
+ ),
+ ),
+ ).toBeInTheDocument();
+ expect(getByTestId('configuration-card-header-edit')).toBeInTheDocument();
+ expect(getByTestId('configuration-card-header-delete')).toBeInTheDocument();
+ });
+
+ it('expands/collapses the container experiment configuration on title click', () => {
+ const { queryByTestId, getByTestId } = renderComponent();
+ const cardTitle = getByTestId('configuration-card-header-button');
+ userEvent.click(cardTitle);
+ expect(queryByTestId('configuration-card-content')).toBeInTheDocument();
+
+ userEvent.click(cardTitle);
+ expect(queryByTestId('configuration-card-content')).not.toBeInTheDocument();
+ });
+
+ it('renders experiment configuration without access to units', () => {
+ const experimentConfigurationUpdated = {
+ ...experimentConfiguration,
+ usage: [],
+ };
+ const { queryByText, getByTestId } = renderComponent({
+ configuration: experimentConfigurationUpdated,
+ });
+ expect(
+ queryByText(commonMessages.accessTo.defaultMessage),
+ ).not.toBeInTheDocument();
+
+ const cardTitle = getByTestId('configuration-card-header-button');
+ userEvent.click(cardTitle);
+ expect(
+ getByTestId('experiment-configuration-card-usage-empty'),
+ ).toBeInTheDocument();
+ });
+
+ it('renders usage with validation error message', () => {
+ const experimentConfigurationUpdated = {
+ ...experimentConfiguration,
+ usage: [{
+ label: 'Unit1name / Content Experiment',
+ url: '/container/block-v1:2u+1+1+type@split_test+block@ccfae830ec9b406c835f8ce4520ae395',
+ validation: {
+ type: 'warning',
+ text: 'This content experiment has issues that affect content visibility.',
+ },
+ }],
+ };
+ const { getByText, getByTestId } = renderComponent({
+ configuration: experimentConfigurationUpdated,
+ });
+
+ const cardTitle = getByTestId('configuration-card-header-button');
+ userEvent.click(cardTitle);
+
+ expect(
+ getByText(experimentConfigurationUpdated.usage[0].validation.text),
+ ).toBeInTheDocument();
+ });
+
+ it('renders experiment configuration badge that contains groups', () => {
+ const { queryByTestId } = renderComponent();
+
+ const usageBlock = queryByTestId('configuration-card-header-button-usage');
+ expect(usageBlock).toBeInTheDocument();
+ });
+
+ it("user can't delete experiment configuration that is used in location", () => {
+ const usageLocation = {
+ label: 'UnitName 2 / Content Experiment',
+ url: '/container/block-v1:2u+1+1+type@split_test+block@ccfae830ec9b406c835f8ce4520ae396',
+ };
+ const experimentConfigurationUpdated = {
+ ...experimentConfiguration,
+ usage: [usageLocation],
+ };
+ const { getByTestId } = renderComponent({
+ configuration: experimentConfigurationUpdated,
+ });
+ const deleteButton = getByTestId('configuration-card-header-delete');
+ expect(deleteButton).toBeDisabled();
+ });
+});
diff --git a/src/group-configurations/experiment-configurations-section/ExperimentCardGroup.jsx b/src/group-configurations/experiment-configurations-section/ExperimentCardGroup.jsx
new file mode 100644
index 0000000000..36cfc1a8e8
--- /dev/null
+++ b/src/group-configurations/experiment-configurations-section/ExperimentCardGroup.jsx
@@ -0,0 +1,41 @@
+import PropTypes from 'prop-types';
+import { Stack, Truncate } from '@openedx/paragon';
+
+import { getGroupPercentage } from './utils';
+
+const ExperimentCardGroup = ({ groups }) => {
+ const percentage = getGroupPercentage(groups.length);
+
+ return (
+
+ {groups.map((item) => (
+
+ {item.name}
+ {percentage}
+
+ ))}
+
+ );
+};
+
+ExperimentCardGroup.propTypes = {
+ groups: PropTypes.arrayOf(
+ PropTypes.shape({
+ id: PropTypes.number,
+ name: PropTypes.string,
+ usage: PropTypes.arrayOf(
+ PropTypes.shape({
+ label: PropTypes.string,
+ url: PropTypes.string,
+ }),
+ ),
+ version: PropTypes.number,
+ }),
+ ).isRequired,
+};
+
+export default ExperimentCardGroup;
diff --git a/src/group-configurations/experiment-configurations-section/ExperimentConfigurationsSection.test.jsx b/src/group-configurations/experiment-configurations-section/ExperimentConfigurationsSection.test.jsx
new file mode 100644
index 0000000000..580258006f
--- /dev/null
+++ b/src/group-configurations/experiment-configurations-section/ExperimentConfigurationsSection.test.jsx
@@ -0,0 +1,45 @@
+import { render } from '@testing-library/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+
+import { experimentGroupConfigurationsMock } from '../__mocks__';
+import messages from './messages';
+import ExperimentConfigurationsSection from '.';
+
+const handleCreateMock = jest.fn();
+const handleDeleteMock = jest.fn();
+const handleEditMock = jest.fn();
+const experimentConfigurationActions = {
+ handleCreate: handleCreateMock,
+ handleDelete: handleDeleteMock,
+ handleEdit: handleEditMock,
+};
+
+const renderComponent = (props) => render(
+
+
+ ,
+);
+
+describe(' ', () => {
+ it('renders component correctly', () => {
+ const { getByText, getByRole, getAllByTestId } = renderComponent();
+ expect(getByText(messages.title.defaultMessage)).toBeInTheDocument();
+ expect(
+ getByRole('button', { name: messages.addNewGroup.defaultMessage }),
+ ).toBeInTheDocument();
+ expect(getAllByTestId('configuration-card')).toHaveLength(
+ experimentGroupConfigurationsMock.length,
+ );
+ });
+
+ it('renders empty section', () => {
+ const { getByTestId } = renderComponent({ availableGroups: [] });
+ expect(
+ getByTestId('group-configurations-empty-placeholder'),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/group-configurations/experiment-configurations-section/ExperimentForm.jsx b/src/group-configurations/experiment-configurations-section/ExperimentForm.jsx
new file mode 100644
index 0000000000..83bd238323
--- /dev/null
+++ b/src/group-configurations/experiment-configurations-section/ExperimentForm.jsx
@@ -0,0 +1,164 @@
+import PropTypes from 'prop-types';
+import { FieldArray, Formik } from 'formik';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import {
+ Alert,
+ ActionRow,
+ Button,
+ Form,
+} from '@openedx/paragon';
+import { WarningFilled as WarningFilledIcon } from '@openedx/paragon/icons';
+
+import PromptIfDirty from '../../generic/prompt-if-dirty/PromptIfDirty';
+import ExperimentFormGroups from './ExperimentFormGroups';
+import messages from './messages';
+import { experimentFormValidationSchema } from './validation';
+
+const ExperimentForm = ({
+ isEditMode,
+ initialValues,
+ isUsedInLocation,
+ onCreateClick,
+ onCancelClick,
+ onEditClick,
+}) => {
+ const { formatMessage } = useIntl();
+ const onSubmitForm = isEditMode ? onEditClick : onCreateClick;
+
+ return (
+
+
+
{formatMessage(messages.experimentConfigurationName)}*
+ {isEditMode && (
+
+ {formatMessage(messages.experimentConfigurationId, {
+ id: initialValues.id,
+ })}
+
+ )}
+
+
+ {({
+ values, errors, dirty, handleChange, handleSubmit,
+ }) => (
+ <>
+
+
+
+ {formatMessage(messages.experimentConfigurationNameFeedback)}
+
+ {errors.name && (
+
+ {errors.name}
+
+ )}
+
+
+
+
+ {formatMessage(messages.experimentConfigurationDescription)}
+
+
+
+ {formatMessage(
+ messages.experimentConfigurationDescriptionFeedback,
+ )}
+
+
+
+ (
+ arrayHelpers.remove(idx)}
+ onCreateGroup={(newGroup) => arrayHelpers.push(newGroup)}
+ />
+ )}
+ />
+
+ {isUsedInLocation && (
+
+ {formatMessage(messages.experimentConfigurationAlert)}
+
+ )}
+
+
+ {formatMessage(messages.experimentConfigurationCancel)}
+
+
+ {formatMessage(
+ isEditMode
+ ? messages.experimentConfigurationSave
+ : messages.experimentConfigurationCreate,
+ )}
+
+
+
+ >
+ )}
+
+
+ );
+};
+
+ExperimentForm.defaultProps = {
+ isEditMode: false,
+ isUsedInLocation: false,
+ onCreateClick: null,
+ onEditClick: null,
+};
+
+ExperimentForm.propTypes = {
+ isEditMode: PropTypes.bool,
+ initialValues: PropTypes.shape({
+ id: PropTypes.number,
+ name: PropTypes.string,
+ description: PropTypes.string,
+ groups: PropTypes.arrayOf(
+ PropTypes.shape({
+ id: PropTypes.number,
+ groupName: PropTypes.string,
+ }),
+ ),
+ }).isRequired,
+ isUsedInLocation: PropTypes.bool,
+ onCreateClick: PropTypes.func,
+ onCancelClick: PropTypes.func.isRequired,
+ onEditClick: PropTypes.func,
+};
+
+export default ExperimentForm;
diff --git a/src/group-configurations/experiment-configurations-section/ExperimentForm.test.jsx b/src/group-configurations/experiment-configurations-section/ExperimentForm.test.jsx
new file mode 100644
index 0000000000..58ec1e8047
--- /dev/null
+++ b/src/group-configurations/experiment-configurations-section/ExperimentForm.test.jsx
@@ -0,0 +1,236 @@
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import userEvent from '@testing-library/user-event';
+import { render, waitFor } from '@testing-library/react';
+
+import { experimentGroupConfigurationsMock } from '../__mocks__';
+import messages from './messages';
+import { initialExperimentConfiguration } from './constants';
+import ExperimentForm from './ExperimentForm';
+
+const onCreateClickMock = jest.fn();
+const onCancelClickMock = jest.fn();
+const onEditClickMock = jest.fn();
+
+const experimentConfiguration = experimentGroupConfigurationsMock[0];
+
+const renderComponent = (props = {}) => render(
+
+
+ ,
+);
+
+describe(' ', () => {
+ it('renders component correctly', () => {
+ const { getByText, getByRole, getByTestId } = renderComponent();
+
+ expect(getByTestId('experiment-configuration-form')).toBeInTheDocument();
+ expect(
+ getByText(`${messages.experimentConfigurationName.defaultMessage}*`),
+ ).toBeInTheDocument();
+ expect(
+ getByRole('button', {
+ name: messages.experimentConfigurationCancel.defaultMessage,
+ }),
+ ).toBeInTheDocument();
+ expect(
+ getByRole('button', {
+ name: messages.experimentConfigurationCreate.defaultMessage,
+ }),
+ ).toBeInTheDocument();
+ });
+
+ it('renders component in edit mode', () => {
+ const { getByText, getByRole } = renderComponent({
+ isEditMode: true,
+ initialValues: experimentConfiguration,
+ });
+
+ expect(
+ getByText(
+ messages.experimentConfigurationId.defaultMessage.replace(
+ '{id}',
+ experimentConfiguration.id,
+ ),
+ ),
+ ).toBeInTheDocument();
+ expect(
+ getByRole('button', {
+ name: messages.experimentConfigurationSave.defaultMessage,
+ }),
+ ).toBeInTheDocument();
+ });
+
+ it('shows alert if group is used in location with edit mode', () => {
+ const { getByText } = renderComponent({
+ isEditMode: true,
+ initialValues: experimentConfiguration,
+ isUsedInLocation: true,
+ });
+ expect(
+ getByText(messages.experimentConfigurationAlert.defaultMessage),
+ ).toBeInTheDocument();
+ });
+
+ it('calls onCreateClick when the "Create" button is clicked with a valid form', async () => {
+ const { getByRole, getByPlaceholderText } = renderComponent();
+ const nameInput = getByPlaceholderText(
+ messages.experimentConfigurationNamePlaceholder.defaultMessage,
+ );
+ const descriptionInput = getByPlaceholderText(
+ messages.experimentConfigurationNamePlaceholder.defaultMessage,
+ );
+ userEvent.type(nameInput, 'New name of the group configuration');
+ userEvent.type(
+ descriptionInput,
+ 'New description of the group configuration',
+ );
+ const createButton = getByRole('button', {
+ name: messages.experimentConfigurationCreate.defaultMessage,
+ });
+ expect(createButton).toBeInTheDocument();
+ userEvent.click(createButton);
+
+ await waitFor(() => {
+ expect(onCreateClickMock).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('shows error when the "Create" button is clicked with empty name', async () => {
+ const { getByRole, getByPlaceholderText, getByText } = renderComponent();
+ const nameInput = getByPlaceholderText(
+ messages.experimentConfigurationNamePlaceholder.defaultMessage,
+ );
+ userEvent.type(nameInput, '');
+
+ const createButton = getByRole('button', {
+ name: messages.experimentConfigurationCreate.defaultMessage,
+ });
+ expect(createButton).toBeInTheDocument();
+ userEvent.click(createButton);
+
+ await waitFor(() => {
+ expect(
+ getByText(messages.experimentConfigurationNameRequired.defaultMessage),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('shows error when the "Create" button is clicked without groups', async () => {
+ const experimentConfigurationUpdated = {
+ ...experimentConfiguration,
+ name: 'My group configuration name',
+ groups: [],
+ };
+ const { getByRole, getByText } = renderComponent({
+ initialValues: experimentConfigurationUpdated,
+ });
+ const createButton = getByRole('button', {
+ name: messages.experimentConfigurationCreate.defaultMessage,
+ });
+ expect(createButton).toBeInTheDocument();
+ userEvent.click(createButton);
+
+ await waitFor(() => {
+ expect(
+ getByText(messages.experimentConfigurationGroupsRequired.defaultMessage),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('shows error when the "Create" button is clicked with duplicate groups', async () => {
+ const experimentConfigurationUpdated = {
+ ...experimentConfiguration,
+ name: 'My group configuration name',
+ groups: [
+ {
+ name: 'Group A',
+ },
+ {
+ name: 'Group A',
+ },
+ ],
+ };
+ const { getByRole, getByText } = renderComponent({
+ initialValues: experimentConfigurationUpdated,
+ });
+ const createButton = getByRole('button', {
+ name: messages.experimentConfigurationCreate.defaultMessage,
+ });
+ expect(createButton).toBeInTheDocument();
+ userEvent.click(createButton);
+
+ await waitFor(() => {
+ expect(
+ getByText(
+ messages.experimentConfigurationGroupsNameUnique.defaultMessage,
+ ),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('shows error when the "Create" button is clicked with empty name of group', async () => {
+ const experimentConfigurationUpdated = {
+ ...experimentConfiguration,
+ name: 'My group configuration name',
+ groups: [
+ {
+ name: '',
+ },
+ ],
+ };
+ const { getByRole, getByText } = renderComponent({
+ initialValues: experimentConfigurationUpdated,
+ });
+ const createButton = getByRole('button', {
+ name: messages.experimentConfigurationCreate.defaultMessage,
+ });
+ expect(createButton).toBeInTheDocument();
+ userEvent.click(createButton);
+
+ await waitFor(() => {
+ expect(
+ getByText(
+ messages.experimentConfigurationGroupsNameRequired.defaultMessage,
+ ),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('calls onEditClick when the "Save" button is clicked with a valid form', async () => {
+ const { getByRole, getByPlaceholderText } = renderComponent({
+ isEditMode: true,
+ initialValues: experimentConfiguration,
+ });
+ const newConfigurationNameText = 'Updated experiment configuration name';
+ const nameInput = getByPlaceholderText(
+ messages.experimentConfigurationNamePlaceholder.defaultMessage,
+ );
+ userEvent.type(nameInput, newConfigurationNameText);
+ const saveButton = getByRole('button', {
+ name: messages.experimentConfigurationSave.defaultMessage,
+ });
+ expect(saveButton).toBeInTheDocument();
+ userEvent.click(saveButton);
+
+ await waitFor(() => {
+ expect(onEditClickMock).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('calls onCancelClick when the "Cancel" button is clicked', async () => {
+ const { getByRole } = renderComponent();
+ const cancelButton = getByRole('button', {
+ name: messages.experimentConfigurationCancel.defaultMessage,
+ });
+ expect(cancelButton).toBeInTheDocument();
+ userEvent.click(cancelButton);
+
+ expect(onCancelClickMock).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/group-configurations/experiment-configurations-section/ExperimentFormGroups.jsx b/src/group-configurations/experiment-configurations-section/ExperimentFormGroups.jsx
new file mode 100644
index 0000000000..c20bfe93f1
--- /dev/null
+++ b/src/group-configurations/experiment-configurations-section/ExperimentFormGroups.jsx
@@ -0,0 +1,124 @@
+/* eslint-disable react/no-array-index-key */
+import PropTypes from 'prop-types';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Close as CloseIcon, Add as AddIcon } from '@openedx/paragon/icons';
+import {
+ Form, Icon, IconButtonWithTooltip, Stack, Button,
+} from '@openedx/paragon';
+
+import {
+ getNextGroupName,
+ getGroupPercentage,
+ getFormGroupErrors,
+} from './utils';
+import messages from './messages';
+
+const ExperimentFormGroups = ({
+ groups,
+ errors,
+ onChange,
+ onDeleteGroup,
+ onCreateGroup,
+}) => {
+ const { formatMessage } = useIntl();
+ const percentage = getGroupPercentage(groups.length);
+ const { arrayErrors, stringError } = getFormGroupErrors(errors);
+
+ return (
+
+
+ {formatMessage(messages.experimentConfigurationGroups)}*
+
+
+ {formatMessage(messages.experimentConfigurationGroupsFeedback)}
+
+ {stringError && (
+
+ {stringError}
+
+ )}
+
+ {groups.map((group, idx) => {
+ const fieldError = arrayErrors?.[idx]?.name;
+ const isInvalid = !!fieldError;
+
+ return (
+
+
+
+
+ {percentage}
+
+ onDeleteGroup(idx)}
+ />
+
+ {isInvalid && (
+
+ {fieldError}
+
+ )}
+
+ );
+ })}
+
+ onCreateGroup(getNextGroupName(groups))}
+ iconBefore={AddIcon}
+ block
+ >
+ {formatMessage(messages.experimentConfigurationGroupsAdd)}
+
+
+ );
+};
+
+ExperimentFormGroups.defaultProps = {
+ errors: [],
+};
+
+ExperimentFormGroups.propTypes = {
+ groups: PropTypes.arrayOf(
+ PropTypes.shape({
+ name: PropTypes.string,
+ version: PropTypes.number,
+ usage: PropTypes.arrayOf(
+ PropTypes.shape({
+ label: PropTypes.string,
+ url: PropTypes.string,
+ validation: PropTypes.shape({
+ type: PropTypes.string,
+ text: PropTypes.string,
+ }),
+ }),
+ ),
+ }),
+ ).isRequired,
+ onChange: PropTypes.func.isRequired,
+ onDeleteGroup: PropTypes.func.isRequired,
+ onCreateGroup: PropTypes.func.isRequired,
+ errors: PropTypes.oneOfType([
+ PropTypes.arrayOf(PropTypes.shape({ name: PropTypes.string })),
+ PropTypes.string,
+ ]),
+};
+
+export default ExperimentFormGroups;
diff --git a/src/group-configurations/experiment-configurations-section/constants.js b/src/group-configurations/experiment-configurations-section/constants.js
new file mode 100644
index 0000000000..70ed39bc88
--- /dev/null
+++ b/src/group-configurations/experiment-configurations-section/constants.js
@@ -0,0 +1,18 @@
+export const ALPHABET_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
+export const initialExperimentConfiguration = {
+ name: '',
+ description: '',
+ groups: [
+ {
+ name: 'Group A', version: 1, usage: [], idx: 0,
+ },
+ {
+ name: 'Group B', version: 1, usage: [], idx: 1,
+ },
+ ],
+ scheme: 'random',
+ parameters: {},
+ usage: [],
+ active: true,
+ version: 1,
+};
diff --git a/src/group-configurations/experiment-configurations-section/index.jsx b/src/group-configurations/experiment-configurations-section/index.jsx
new file mode 100644
index 0000000000..a5ed9f6365
--- /dev/null
+++ b/src/group-configurations/experiment-configurations-section/index.jsx
@@ -0,0 +1,115 @@
+import PropTypes from 'prop-types';
+import { Button, useToggle } from '@openedx/paragon';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Add as AddIcon } from '@openedx/paragon/icons';
+
+import { useScrollToHashElement } from '../../hooks';
+import EmptyPlaceholder from '../empty-placeholder';
+import ExperimentForm from './ExperimentForm';
+import ExperimentCard from './ExperimentCard';
+import { initialExperimentConfiguration } from './constants';
+import messages from './messages';
+
+const ExperimentConfigurationsSection = ({
+ availableGroups,
+ experimentConfigurationActions,
+}) => {
+ const { formatMessage } = useIntl();
+ const [
+ isNewConfigurationVisible,
+ openNewConfiguration,
+ hideNewConfiguration,
+ ] = useToggle(false);
+
+ const handleCreateConfiguration = (configuration) => {
+ experimentConfigurationActions.handleCreate(configuration, hideNewConfiguration);
+ };
+
+ const { elementWithHash } = useScrollToHashElement({ isLoading: true });
+
+ return (
+
+
+ {formatMessage(messages.title)}
+
+ {availableGroups.length ? (
+ <>
+ {availableGroups.map((configuration) => (
+
+ ))}
+ {!isNewConfigurationVisible && (
+
+ {formatMessage(messages.addNewGroup)}
+
+ )}
+ >
+ ) : (
+ !isNewConfigurationVisible && (
+
+ )
+ )}
+ {isNewConfigurationVisible && (
+
+ )}
+
+ );
+};
+
+ExperimentConfigurationsSection.defaultProps = {
+ availableGroups: [],
+};
+
+ExperimentConfigurationsSection.propTypes = {
+ availableGroups: PropTypes.arrayOf(
+ PropTypes.shape({
+ active: PropTypes.bool,
+ description: PropTypes.string,
+ groups: PropTypes.arrayOf(
+ PropTypes.shape({
+ id: PropTypes.number,
+ name: PropTypes.string,
+ usage: PropTypes.arrayOf(
+ PropTypes.shape({
+ label: PropTypes.string,
+ url: PropTypes.string,
+ }),
+ ),
+ version: PropTypes.number,
+ }).isRequired,
+ ),
+ id: PropTypes.number,
+ name: PropTypes.string,
+ parameters: PropTypes.shape({
+ courseId: PropTypes.string,
+ }),
+ readOnly: PropTypes.bool,
+ scheme: PropTypes.string,
+ version: PropTypes.number,
+ }).isRequired,
+ ),
+ experimentConfigurationActions: PropTypes.shape({
+ handleCreate: PropTypes.func,
+ handleDelete: PropTypes.func,
+ }).isRequired,
+};
+
+export default ExperimentConfigurationsSection;
diff --git a/src/group-configurations/experiment-configurations-section/messages.js b/src/group-configurations/experiment-configurations-section/messages.js
new file mode 100644
index 0000000000..d2370226c7
--- /dev/null
+++ b/src/group-configurations/experiment-configurations-section/messages.js
@@ -0,0 +1,146 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ title: {
+ id: 'course-authoring.group-configurations.experiment-configuration.title',
+ defaultMessage: 'Experiment group configurations',
+ description: 'Title for the page displaying experiment group configurations.',
+ },
+ addNewGroup: {
+ id: 'course-authoring.group-configurations.experiment-group.add-new-group',
+ defaultMessage: 'New group configuration',
+ description: 'Label for adding a new experiment group configuration.',
+ },
+ experimentConfigurationName: {
+ id: 'course-authoring.group-configurations.experiment-configuration.container.name',
+ defaultMessage: 'Group configuration name',
+ description: 'Label for the input field to enter the name of an experiment group configuration.',
+ },
+ experimentConfigurationId: {
+ id: 'course-authoring.group-configurations.experiment-configuration.container.id',
+ defaultMessage: 'Group configuration ID {id}',
+ description: 'Label displaying the ID of an experiment group configuration.',
+ },
+ experimentConfigurationNameFeedback: {
+ id: 'course-authoring.group-configurations.experiment-configuration.container.name.feedback',
+ defaultMessage: 'Name or short description of the configuration.',
+ description: 'Feedback message for the name/description input field of an experiment group configuration.',
+ },
+ experimentConfigurationNamePlaceholder: {
+ id: 'course-authoring.group-configurations.experiment-configuration.container.name.placeholder',
+ defaultMessage: 'This is the name of the group configuration',
+ description: 'Placeholder text for the name input field of an experiment group configuration.',
+ },
+ experimentConfigurationNameRequired: {
+ id: 'course-authoring.group-configurations.experiment-configuration.container.name.required',
+ defaultMessage: 'Group configuration name is required.',
+ description: 'Error message displayed when the name of the experiment group configuration is required but not provided.',
+ },
+ experimentConfigurationDescription: {
+ id: 'course-authoring.group-configurations.experiment-configuration.container.description',
+ defaultMessage: 'Description',
+ description: 'Label for the description input field of an experiment group configuration.',
+ },
+ experimentConfigurationDescriptionFeedback: {
+ id: 'course-authoring.group-configurations.experiment-configuration.container.description.feedback',
+ defaultMessage: 'Optional long description.',
+ description: 'Feedback message for the description input field of an experiment group configuration.',
+ },
+ experimentConfigurationDescriptionPlaceholder: {
+ id: 'course-authoring.group-configurations.experiment-configuration.container.description.placeholder',
+ defaultMessage: 'This is the description of the group configuration',
+ description: 'Placeholder text for the description input field of an experiment group configuration.',
+ },
+ experimentConfigurationGroups: {
+ id: 'course-authoring.group-configurations.experiment-configuration.container.groups',
+ defaultMessage: 'Groups',
+ description: 'Label for the section displaying groups within an experiment group configuration.',
+ },
+ experimentConfigurationGroupsFeedback: {
+ id: 'course-authoring.group-configurations.experiment-configuration.container.groups.feedback',
+ defaultMessage: 'Name of the groups that students will be assigned to, for example, Control, Video, Problems. You must have two or more groups.',
+ description: 'Feedback message for the groups section of an experiment group configuration.',
+ },
+ experimentConfigurationGroupsNameRequired: {
+ id: 'course-authoring.group-configurations.experiment-configuration.container.groups.name.required',
+ defaultMessage: 'All groups must have a name.',
+ description: 'Error message displayed when the name of a group within an experiment group configuration is required but not provided.',
+ },
+ experimentConfigurationGroupsNameUnique: {
+ id: 'course-authoring.group-configurations.experiment-configuration.container.groups.name.unique',
+ defaultMessage: 'All groups must have a unique name.',
+ description: 'Error message displayed when the names of groups within an experiment group configuration are not unique.',
+ },
+ experimentConfigurationGroupsRequired: {
+ id: 'course-authoring.group-configurations.experiment-configuration.container.groups.required',
+ defaultMessage: 'There must be at least one group.',
+ description: 'Error message displayed when at least one group is required within an experiment group configuration.',
+ },
+ experimentConfigurationGroupsTooltip: {
+ id: 'course-authoring.group-configurations.experiment-configuration.container.groups.tooltip',
+ defaultMessage: 'Delete',
+ description: 'Tooltip message for the delete action within the groups section of an experiment group configuration.',
+ },
+ experimentConfigurationGroupsAdd: {
+ id: 'course-authoring.group-configurations.experiment-configuration.container.groups.add',
+ defaultMessage: 'Add another group',
+ description: 'Label for the button to add another group within the groups section of an experiment group configuration.',
+ },
+ experimentConfigurationDeleteRestriction: {
+ id: 'course-authoring.group-configurations.experiment-configuration.container.delete.restriction',
+ defaultMessage: 'Cannot delete when in use by an experiment',
+ description: 'Error message indicating that an experiment group configuration cannot be deleted because it is currently in use by an experiment.',
+ },
+ experimentConfigurationCancel: {
+ id: 'course-authoring.group-configurations.experiment-configuration.container.cancel',
+ defaultMessage: 'Cancel',
+ description: 'Label for the cancel button within an experiment group configuration.',
+ },
+ experimentConfigurationSave: {
+ id: 'course-authoring.group-configurations.experiment-configuration.container.save',
+ defaultMessage: 'Save',
+ description: 'Label for the save button within an experiment group configuration.',
+ },
+ experimentConfigurationCreate: {
+ id: 'course-authoring.group-configurations.experiment-configuration.container.create',
+ defaultMessage: 'Create',
+ description: 'Label for the create button within an experiment group configuration.',
+ },
+ experimentConfigurationAlert: {
+ id: 'course-authoring.group-configurations.experiment-configuration.container.alert',
+ defaultMessage: 'This configuration is currently used in content experiments. If you make changes to the groups, you may need to edit those experiments.',
+ description: 'Alert message indicating that an experiment group configuration is currently used in content experiments and that changes may require editing those experiments.',
+ },
+ emptyExperimentGroup: {
+ id: 'course-authoring.group-configurations.experiment-card.empty-experiment-group',
+ defaultMessage: 'This group configuration is not in use. Start by adding a content experiment to any Unit via the {outlineComponentLink}.',
+ description: 'Message displayed when an experiment group configuration is not in use and suggests adding a content experiment.',
+ },
+ courseOutline: {
+ id: 'course-authoring.group-configurations.experiment-card.course-outline',
+ defaultMessage: 'Course outline',
+ description: 'Label for the course outline section within an experiment card.',
+ },
+ actionEdit: {
+ id: 'course-authoring.group-configurations.experiment-card.action.edit',
+ defaultMessage: 'Edit',
+ description: 'Label for the edit action within an experiment card.',
+ },
+ actionDelete: {
+ id: 'course-authoring.group-configurations.experiment-card.action.delete',
+ defaultMessage: 'Delete',
+ description: 'Label for the delete action within an experiment card.',
+ },
+ subtitleModalDelete: {
+ id: 'course-authoring.group-configurations.experiment-card.delete-modal.subtitle',
+ defaultMessage: 'group configurations',
+ description: 'Subtitle for the delete modal indicating the type of entity being deleted.',
+ },
+ deleteRestriction: {
+ id: 'course-authoring.group-configurations.experiment-card.delete-restriction',
+ defaultMessage: 'Cannot delete when in use by a unit',
+ description: 'Error message indicating that an experiment card cannot be deleted because it is currently in use by a unit.',
+ },
+});
+
+export default messages;
diff --git a/src/group-configurations/experiment-configurations-section/utils.js b/src/group-configurations/experiment-configurations-section/utils.js
new file mode 100644
index 0000000000..18d070ecf6
--- /dev/null
+++ b/src/group-configurations/experiment-configurations-section/utils.js
@@ -0,0 +1,73 @@
+import { isArray } from 'lodash';
+
+import { ALPHABET_LETTERS } from './constants';
+
+/**
+ * Generates the next unique group name based on existing group names.
+ * @param {Array} groups - An array of group objects.
+ * @param {string} groupFieldName - Optional. The name of the field containing the group name. Default is 'name'.
+ * @returns {Object} An object containing the next unique group name, along with additional information.
+ */
+const getNextGroupName = (groups, groupFieldName = 'name') => {
+ const existingGroupNames = groups.map((group) => group.name);
+ const lettersCount = ALPHABET_LETTERS.length;
+
+ // Calculate the maximum index of existing groups
+ const maxIdx = groups.reduce((max, group) => Math.max(max, group.idx), -1);
+
+ // Calculate the next index for the new group
+ const nextIndex = maxIdx + 1;
+
+ let groupName = '';
+ let counter = 0;
+
+ do {
+ let tempIndex = nextIndex + counter;
+ groupName = '';
+ while (tempIndex >= 0) {
+ groupName = ALPHABET_LETTERS[tempIndex % lettersCount] + groupName;
+ tempIndex = Math.floor(tempIndex / lettersCount) - 1;
+ }
+ counter++;
+ } while (existingGroupNames.includes(`Group ${groupName}`));
+
+ return {
+ [groupFieldName]: `Group ${groupName}`, version: 1, usage: [], idx: nextIndex,
+ };
+};
+
+/**
+ * Calculates the percentage of groups values of total groups.
+ * @param {number} totalGroups - Total number of groups.
+ * @returns {string} The percentage of groups, each group has the same value.
+ */
+const getGroupPercentage = (totalGroups) => (totalGroups === 0 ? '0%' : `${Math.floor(100 / totalGroups)}%`);
+
+/**
+ * Checks if all group names in the array are unique.
+ * @param {Array} groups - An array of group objects.
+ * @returns {boolean} True if all group names are unique, otherwise false.
+ */
+const allGroupNamesAreUnique = (groups) => {
+ const names = groups.map((group) => group.name);
+ return new Set(names).size === names.length;
+};
+
+/**
+ * Formats form group errors into an object. Because we need to handle both type errors.
+ * @param {Array|string} errors - The form group errors.
+ * @returns {Object} An object containing arrayErrors and stringError properties.
+ */
+const getFormGroupErrors = (errors) => {
+ const arrayErrors = isArray(errors) ? errors : [];
+ const stringError = isArray(errors) ? '' : errors || '';
+
+ return { arrayErrors, stringError };
+};
+
+export {
+ allGroupNamesAreUnique,
+ getNextGroupName,
+ getGroupPercentage,
+ getFormGroupErrors,
+};
diff --git a/src/group-configurations/experiment-configurations-section/utils.test.js b/src/group-configurations/experiment-configurations-section/utils.test.js
new file mode 100644
index 0000000000..4e0e5f9272
--- /dev/null
+++ b/src/group-configurations/experiment-configurations-section/utils.test.js
@@ -0,0 +1,174 @@
+import {
+ allGroupNamesAreUnique,
+ getNextGroupName,
+ getGroupPercentage,
+} from './utils';
+
+describe('utils module', () => {
+ describe('getNextGroupName', () => {
+ it('return correct next group name test-case-1', () => {
+ const groups = [
+ {
+ name: 'Group A', idx: 0,
+ },
+ {
+ name: 'Group B', idx: 1,
+ },
+ ];
+ const nextGroup = getNextGroupName(groups);
+ expect(nextGroup.name).toBe('Group C');
+ expect(nextGroup.idx).toBe(2);
+ });
+
+ it('return correct next group name test-case-2', () => {
+ const groups = [];
+ const nextGroup = getNextGroupName(groups);
+ expect(nextGroup.name).toBe('Group A');
+ expect(nextGroup.idx).toBe(0);
+ });
+
+ it('return correct next group name test-case-3', () => {
+ const groups = [
+ {
+ name: 'Some group', idx: 0,
+ },
+ {
+ name: 'Group B', idx: 1,
+ },
+ ];
+ const nextGroup = getNextGroupName(groups);
+ expect(nextGroup.name).toBe('Group C');
+ expect(nextGroup.idx).toBe(2);
+ });
+
+ it('return correct next group name test-case-4', () => {
+ const groups = [
+ {
+ name: 'Group A', idx: 0,
+ },
+ {
+ name: 'Group A', idx: 1,
+ },
+ ];
+ const nextGroup = getNextGroupName(groups);
+ expect(nextGroup.name).toBe('Group C');
+ expect(nextGroup.idx).toBe(2);
+ });
+
+ it('return correct next group name test-case-5', () => {
+ const groups = [
+ {
+ name: 'Group A', idx: 0,
+ },
+ {
+ name: 'Group C', idx: 1,
+ },
+ {
+ name: 'Group B', idx: 2,
+ },
+ ];
+ const nextGroup = getNextGroupName(groups);
+ expect(nextGroup.name).toBe('Group D');
+ expect(nextGroup.idx).toBe(3);
+ });
+
+ it('return correct next group name test-case-6', () => {
+ const groups = [
+ {
+ name: '', idx: 0,
+ },
+ {
+ name: '', idx: 1,
+ },
+ ];
+ const nextGroup = getNextGroupName(groups);
+ expect(nextGroup.name).toBe('Group C');
+ expect(nextGroup.idx).toBe(2);
+ });
+
+ it('return correct next group name test-case-7', () => {
+ const groups = [
+ {
+ name: 'Group A', idx: 0,
+ },
+ {
+ name: 'Group C', idx: 1,
+ },
+ ];
+ const nextGroup = getNextGroupName(groups);
+ expect(nextGroup.name).toBe('Group D');
+ expect(nextGroup.idx).toBe(2);
+ });
+
+ it('return correct next group name test-case-8', () => {
+ const groups = [
+ {
+ name: 'Group D', idx: 0,
+ },
+ ];
+ const nextGroup = getNextGroupName(groups);
+ expect(nextGroup.name).toBe('Group B');
+ expect(nextGroup.idx).toBe(1);
+ });
+
+ it('return correct next group name test-case-9', () => {
+ const groups = [
+ {
+ name: 'Group E', idx: 4,
+ },
+ ];
+ const nextGroup = getNextGroupName(groups);
+ expect(nextGroup.name).toBe('Group F');
+ });
+
+ it('return correct next group name test-case-10', () => {
+ const groups = [
+ {
+ name: 'Group E', idx: 0,
+ },
+ ];
+ const nextGroup = getNextGroupName(groups);
+ expect(nextGroup.name).toBe('Group B');
+ });
+
+ it('return correct next group name test-case-11', () => {
+ const simulatedGroupWithAlphabetLength = Array.from(
+ { length: 26 },
+ (_, idx) => ({ name: 'Test name', idx }),
+ );
+ const nextGroup = getNextGroupName(simulatedGroupWithAlphabetLength);
+ expect(nextGroup.name).toBe('Group AA');
+ });
+
+ it('return correct next group name test-case-12', () => {
+ const simulatedGroupWithAlphabetLength = Array.from(
+ { length: 702 },
+ (_, idx) => ({ name: 'Test name', idx }),
+ );
+ const nextGroup = getNextGroupName(simulatedGroupWithAlphabetLength);
+ expect(nextGroup.name).toBe('Group AAA');
+ });
+ });
+
+ describe('getGroupPercentage', () => {
+ it('calculates group percentage correctly', () => {
+ expect(getGroupPercentage(1)).toBe('100%');
+ expect(getGroupPercentage(7)).toBe('14%');
+ expect(getGroupPercentage(10)).toBe('10%');
+ expect(getGroupPercentage(26)).toBe('3%');
+ expect(getGroupPercentage(100)).toBe('1%');
+ });
+ });
+
+ describe('allGroupNamesAreUnique', () => {
+ it('returns true if all group names are unique', () => {
+ const groups = [{ name: 'A' }, { name: 'B' }, { name: 'C' }];
+ expect(allGroupNamesAreUnique(groups)).toBe(true);
+ });
+
+ it('returns false if any group names are not unique', () => {
+ const groups = [{ name: 'A' }, { name: 'B' }, { name: 'A' }];
+ expect(allGroupNamesAreUnique(groups)).toBe(false);
+ });
+ });
+});
diff --git a/src/group-configurations/experiment-configurations-section/validation.js b/src/group-configurations/experiment-configurations-section/validation.js
new file mode 100644
index 0000000000..e1d02df0d4
--- /dev/null
+++ b/src/group-configurations/experiment-configurations-section/validation.js
@@ -0,0 +1,45 @@
+import * as Yup from 'yup';
+
+import messages from './messages';
+import { allGroupNamesAreUnique } from './utils';
+
+// eslint-disable-next-line import/prefer-default-export
+export const experimentFormValidationSchema = (formatMessage) => Yup.object().shape({
+ id: Yup.number(),
+ name: Yup.string()
+ .trim()
+ .required(formatMessage(messages.experimentConfigurationNameRequired)),
+ description: Yup.string(),
+ groups: Yup.array()
+ .of(
+ Yup.object().shape({
+ id: Yup.number(),
+ name: Yup.string()
+ .trim()
+ .required(
+ formatMessage(messages.experimentConfigurationGroupsNameRequired),
+ ),
+ version: Yup.number(),
+ usage: Yup.array().nullable(true),
+ }),
+ )
+ .required()
+ .min(1, formatMessage(messages.experimentConfigurationGroupsRequired))
+ .test(
+ 'unique-group-name-restriction',
+ formatMessage(messages.experimentConfigurationGroupsNameUnique),
+ (values) => allGroupNamesAreUnique(values),
+ ),
+ scheme: Yup.string(),
+ version: Yup.number(),
+ parameters: Yup.object(),
+ usage: Yup.array()
+ .of(
+ Yup.object().shape({
+ label: Yup.string(),
+ url: Yup.string(),
+ }),
+ )
+ .nullable(true),
+ active: Yup.bool(),
+});
diff --git a/src/group-configurations/group-configuration-sidebar/GroupConfigurationSidebar.test.jsx b/src/group-configurations/group-configuration-sidebar/GroupConfigurationSidebar.test.jsx
new file mode 100644
index 0000000000..eb5cb99886
--- /dev/null
+++ b/src/group-configurations/group-configuration-sidebar/GroupConfigurationSidebar.test.jsx
@@ -0,0 +1,104 @@
+import { render } from '@testing-library/react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { initializeMockApp } from '@edx/frontend-platform';
+import { AppProvider } from '@edx/frontend-platform/react';
+
+import initializeStore from '../../store';
+import GroupConfigurationSidebar from '.';
+import messages from './messages';
+
+let store;
+const courseId = 'course-123';
+const enrollmentTrackTitle = messages.about_3_title.defaultMessage;
+const contentGroupTitle = messages.aboutTitle.defaultMessage;
+const experimentGroupTitle = messages.about_2_title.defaultMessage;
+
+jest.mock('@edx/frontend-platform/i18n', () => ({
+ ...jest.requireActual('@edx/frontend-platform/i18n'),
+ useIntl: () => ({
+ formatMessage: (message) => message.defaultMessage,
+ }),
+}));
+
+const renderComponent = (props) => render(
+
+
+
+ ,
+ ,
+);
+
+describe('GroupConfigurationSidebar', () => {
+ beforeEach(() => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ },
+ });
+ store = initializeStore();
+ });
+
+ it('renders all groups when all props are true', async () => {
+ const { findAllByRole } = renderComponent({
+ shouldShowExperimentGroups: true,
+ shouldShowContentGroup: true,
+ shouldShowEnrollmentTrackGroup: true,
+ });
+ const titles = await findAllByRole('heading', { level: 4 });
+
+ expect(titles[0]).toHaveTextContent(enrollmentTrackTitle);
+ expect(titles[1]).toHaveTextContent(contentGroupTitle);
+ expect(titles[2]).toHaveTextContent(experimentGroupTitle);
+ });
+
+ it('renders no groups when all props are false', async () => {
+ const { queryByText } = renderComponent({
+ shouldShowExperimentGroups: false,
+ shouldShowContentGroup: false,
+ shouldShowEnrollmentTrackGroup: false,
+ });
+
+ expect(queryByText(enrollmentTrackTitle)).not.toBeInTheDocument();
+ expect(queryByText(contentGroupTitle)).not.toBeInTheDocument();
+ expect(queryByText(experimentGroupTitle)).not.toBeInTheDocument();
+ });
+
+ it('renders only content group when shouldShowContentGroup is true', async () => {
+ const { queryByText, getByText } = renderComponent({
+ shouldShowExperimentGroups: false,
+ shouldShowContentGroup: true,
+ shouldShowEnrollmentTrackGroup: false,
+ });
+
+ expect(queryByText(enrollmentTrackTitle)).not.toBeInTheDocument();
+ expect(getByText(contentGroupTitle)).toBeInTheDocument();
+ expect(queryByText(experimentGroupTitle)).not.toBeInTheDocument();
+ });
+
+ it('renders only experiment group when shouldShowExperimentGroups is true', async () => {
+ const { queryByText, getByText } = renderComponent({
+ shouldShowExperimentGroups: true,
+ shouldShowContentGroup: false,
+ shouldShowEnrollmentTrackGroup: false,
+ });
+
+ expect(queryByText(enrollmentTrackTitle)).not.toBeInTheDocument();
+ expect(queryByText(contentGroupTitle)).not.toBeInTheDocument();
+ expect(getByText(experimentGroupTitle)).toBeInTheDocument();
+ });
+
+ it('renders only enrollment track group when shouldShowEnrollmentTrackGroup is true', async () => {
+ const { queryByText, getByText } = renderComponent({
+ shouldShowExperimentGroups: false,
+ shouldShowContentGroup: false,
+ shouldShowEnrollmentTrackGroup: true,
+ });
+
+ expect(getByText(enrollmentTrackTitle)).toBeInTheDocument();
+ expect(queryByText(contentGroupTitle)).not.toBeInTheDocument();
+ expect(queryByText(experimentGroupTitle)).not.toBeInTheDocument();
+ });
+});
diff --git a/src/group-configurations/group-configuration-sidebar/index.jsx b/src/group-configurations/group-configuration-sidebar/index.jsx
new file mode 100644
index 0000000000..99dbf6bc4b
--- /dev/null
+++ b/src/group-configurations/group-configuration-sidebar/index.jsx
@@ -0,0 +1,59 @@
+import { Fragment } from 'react';
+import PropTypes from 'prop-types';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Hyperlink } from '@openedx/paragon';
+
+import { HelpSidebar } from '../../generic/help-sidebar';
+import { useHelpUrls } from '../../help-urls/hooks';
+import { getSidebarData } from './utils';
+import messages from './messages';
+
+const GroupConfigurationSidebar = ({
+ courseId, shouldShowExperimentGroups, shouldShowContentGroup, shouldShowEnrollmentTrackGroup,
+}) => {
+ const intl = useIntl();
+ const urls = useHelpUrls(['groupConfigurations', 'enrollmentTracks', 'contentGroups']);
+ const sidebarData = getSidebarData({
+ messages, intl, shouldShowExperimentGroups, shouldShowContentGroup, shouldShowEnrollmentTrackGroup,
+ });
+
+ return (
+
+ {sidebarData
+ .map(({ title, paragraphs, urlKey }, idx) => (
+
+
+ {title}
+
+ {paragraphs.map((text) => (
+
+ {text}
+
+ ))}
+
+ {intl.formatMessage(messages.learnMoreBtn)}
+
+ {idx !== sidebarData.length - 1 && }
+
+ ))}
+
+ );
+};
+
+GroupConfigurationSidebar.propTypes = {
+ courseId: PropTypes.string.isRequired,
+ shouldShowContentGroup: PropTypes.bool.isRequired,
+ shouldShowExperimentGroups: PropTypes.bool.isRequired,
+ shouldShowEnrollmentTrackGroup: PropTypes.bool.isRequired,
+};
+
+export default GroupConfigurationSidebar;
diff --git a/src/group-configurations/group-configuration-sidebar/messages.js b/src/group-configurations/group-configuration-sidebar/messages.js
new file mode 100644
index 0000000000..3404e8c9cb
--- /dev/null
+++ b/src/group-configurations/group-configuration-sidebar/messages.js
@@ -0,0 +1,81 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ aboutTitle: {
+ id: 'course-authoring.group-configurations.sidebar.about.title',
+ defaultMessage: 'Content groups',
+ description: 'Title for the content groups section in the sidebar.',
+ },
+ aboutDescription_1: {
+ id: 'course-authoring.group-configurations.sidebar.about.description-1',
+ defaultMessage: 'If you have cohorts enabled in your course, you can use content groups to create cohort-specific courseware. In other words, you can customize the content that particular cohorts see in your course.',
+ description: 'First description for the content groups section in the sidebar.',
+ },
+ aboutDescription_2: {
+ id: 'course-authoring.group-configurations.sidebar.about.description-2',
+ defaultMessage: 'Each content group that you create can be associated with one or more cohorts. In addition to making course content available to all learners, you can restrict access to some content to learners in specific content groups. Only learners in the cohorts that are associated with the specified content groups see the additional content.',
+ description: 'Second description for the content groups section in the sidebar.',
+ },
+ aboutDescription_3: {
+ id: 'course-authoring.group-configurations.sidebar.about.description-3',
+ defaultMessage: 'Click {strongText} to add a new content group. To edit the name of a content group, hover over its box and click {strongText2}. You can delete a content group only if it is not in use by a unit. To delete a content group, hover over its box and click the delete icon.',
+ description: 'Third description for the content groups section in the sidebar. Mentions how to add, edit, and delete content groups.',
+ },
+ aboutDescription_3_strong: {
+ id: 'course-authoring.group-configurations.sidebar.about.description-3.strong',
+ defaultMessage: 'New content group',
+ description: 'Strong text (button label) used in the third description for adding a new content group.',
+ },
+ about_2_title: {
+ id: 'course-authoring.group-configurations.sidebar.about-2.title',
+ defaultMessage: 'Experiment group configurations',
+ description: 'Title for the experiment group configurations section in the sidebar.',
+ },
+ about_2_description_1: {
+ id: 'course-authoring.group-configurations.sidebar.about-2.description-1',
+ defaultMessage: 'Use experiment group configurations if you are conducting content experiments, also known as A/B testing, in your course. Experiment group configurations define how many groups of learners are in a content experiment. When you create a content experiment for a course, you select the group configuration to use.',
+ description: 'First description for the experiment group configurations section in the sidebar.',
+ },
+ about_2_description_2: {
+ id: 'course-authoring.group-configurations.sidebar.about-2.description-2',
+ defaultMessage: 'Click {strongText} to add a new configuration. To edit a configuration, hover over its box and click {strongText2}. You can delete a group configuration only if it is not in use in an experiment. To delete a configuration, hover over its box and click the delete icon.',
+ description: 'Second description for the experiment group configurations section in the sidebar. Mentions how to add, edit, and delete group configurations.',
+ },
+ about_2_description_2_strong: {
+ id: 'course-authoring.group-configurations.sidebar.about-2.description-2.strong',
+ defaultMessage: 'New group configuration',
+ description: 'Strong text (button label) used in the second description for adding a new group configuration.',
+ },
+ about_3_title: {
+ id: 'course-authoring.group-configurations.sidebar.about-3.title',
+ defaultMessage: 'Enrollment track groups',
+ description: 'Title for the enrollment track groups section in the sidebar.',
+ },
+ about_3_description_1: {
+ id: 'course-authoring.group-configurations.sidebar.about-3.description-1',
+ defaultMessage: 'Enrollment track groups allow you to offer different course content to learners in each enrollment track. Learners enrolled in each enrollment track in your course are automatically included in the corresponding enrollment track group.',
+ description: 'First description for the enrollment track groups section in the sidebar.',
+ },
+ about_3_description_2: {
+ id: 'course-authoring.group-configurations.sidebar.about-3.description-2',
+ defaultMessage: 'On unit pages in the course outline, you can restrict access to components to learners based on their enrollment track.',
+ description: 'Second description for the enrollment track groups section in the sidebar.',
+ },
+ about_3_description_3: {
+ id: 'course-authoring.group-configurations.sidebar.about-3.description-3',
+ defaultMessage: 'You cannot edit enrollment track groups, but you can expand each group to view details of the course content that is designated for learners in the group.',
+ description: 'Third description for the enrollment track groups section in the sidebar. Mentions the limitations and options for managing enrollment track groups.',
+ },
+ aboutDescription_strong_edit: {
+ id: 'course-authoring.group-configurations.sidebar.about.description.strong-edit',
+ defaultMessage: 'edit',
+ description: 'Strong text used to indicate the edit action.',
+ },
+ learnMoreBtn: {
+ id: 'course-authoring.group-configurations.sidebar.learnmore.button',
+ defaultMessage: 'Learn more',
+ description: 'Label for the "Learn more" button in the sidebar.',
+ },
+});
+
+export default messages;
diff --git a/src/group-configurations/group-configuration-sidebar/utils.jsx b/src/group-configurations/group-configuration-sidebar/utils.jsx
new file mode 100644
index 0000000000..d039c8f440
--- /dev/null
+++ b/src/group-configurations/group-configuration-sidebar/utils.jsx
@@ -0,0 +1,57 @@
+/**
+ * Compiles the sidebar data for the course authoring sidebar.
+ *
+ * @param {Object} messages - The localized messages.
+ * @param {Object} intl - The intl object for formatting messages.
+ * @param {boolean} shouldShowExperimentGroups - Flag to include experiment group configuration data.
+ * @param {boolean} shouldShowContentGroup - Flag to include content group data.
+ * @param {boolean} shouldShowEnrollmentTrackGroup - Flag to include enrollment track group data.
+ * @returns {Object[]} The array of sidebar data groups.
+ */
+const getSidebarData = ({
+ messages, intl, shouldShowExperimentGroups, shouldShowContentGroup, shouldShowEnrollmentTrackGroup,
+}) => {
+ const groups = [];
+
+ if (shouldShowEnrollmentTrackGroup) {
+ groups.push({
+ urlKey: 'enrollmentTracks',
+ title: intl.formatMessage(messages.about_3_title),
+ paragraphs: [
+ intl.formatMessage(messages.about_3_description_1),
+ intl.formatMessage(messages.about_3_description_2),
+ intl.formatMessage(messages.about_3_description_3),
+ ],
+ });
+ }
+ if (shouldShowContentGroup) {
+ groups.push({
+ urlKey: 'contentGroups',
+ title: intl.formatMessage(messages.aboutTitle),
+ paragraphs: [
+ intl.formatMessage(messages.aboutDescription_1),
+ intl.formatMessage(messages.aboutDescription_2),
+ intl.formatMessage(messages.aboutDescription_3, {
+ strongText: {intl.formatMessage(messages.aboutDescription_3_strong)} ,
+ strongText2: {intl.formatMessage(messages.aboutDescription_strong_edit)} ,
+ }),
+ ],
+ });
+ }
+ if (shouldShowExperimentGroups) {
+ groups.push({
+ urlKey: 'groupConfigurations',
+ title: intl.formatMessage(messages.about_2_title),
+ paragraphs: [
+ intl.formatMessage(messages.about_2_description_1),
+ intl.formatMessage(messages.about_2_description_2, {
+ strongText: {intl.formatMessage(messages.about_2_description_2_strong)} ,
+ strongText2: {intl.formatMessage(messages.aboutDescription_strong_edit)} ,
+ }),
+ ],
+ });
+ }
+ return groups;
+};
+// eslint-disable-next-line import/prefer-default-export
+export { getSidebarData };
diff --git a/src/group-configurations/hooks.jsx b/src/group-configurations/hooks.jsx
new file mode 100644
index 0000000000..9766776c76
--- /dev/null
+++ b/src/group-configurations/hooks.jsx
@@ -0,0 +1,97 @@
+import { useEffect } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+
+import { RequestStatus } from '../data/constants';
+import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
+import {
+ getGroupConfigurationsData,
+ getLoadingStatus,
+ getSavingStatus,
+} from './data/selectors';
+import { updateSavingStatuses } from './data/slice';
+import {
+ createContentGroupQuery,
+ createExperimentConfigurationQuery,
+ deleteContentGroupQuery,
+ deleteExperimentConfigurationQuery,
+ editContentGroupQuery,
+ editExperimentConfigurationQuery,
+ fetchGroupConfigurationsQuery,
+} from './data/thunk';
+
+const useGroupConfigurations = (courseId) => {
+ const dispatch = useDispatch();
+ const groupConfigurations = useSelector(getGroupConfigurationsData);
+ const loadingStatus = useSelector(getLoadingStatus);
+ const savingStatus = useSelector(getSavingStatus);
+ const {
+ isShow: isShowProcessingNotification,
+ title: processingNotificationTitle,
+ } = useSelector(getProcessingNotification);
+
+ const handleInternetConnectionFailed = () => {
+ dispatch(updateSavingStatuses({ status: RequestStatus.FAILED }));
+ };
+
+ const contentGroupActions = {
+ handleCreate: (group, callbackToClose) => {
+ dispatch(createContentGroupQuery(courseId, group)).then((result) => {
+ if (result) {
+ callbackToClose();
+ }
+ });
+ },
+ handleEdit: (group, callbackToClose) => {
+ dispatch(editContentGroupQuery(courseId, group)).then((result) => {
+ if (result) {
+ callbackToClose();
+ }
+ });
+ },
+ handleDelete: (parentGroupId, groupId) => {
+ dispatch(deleteContentGroupQuery(courseId, parentGroupId, groupId));
+ },
+ };
+
+ const experimentConfigurationActions = {
+ handleCreate: (configuration, callbackToClose) => {
+ dispatch(
+ createExperimentConfigurationQuery(courseId, configuration),
+ ).then((result) => {
+ if (result) {
+ callbackToClose();
+ }
+ });
+ },
+ handleEdit: (configuration, callbackToClose) => {
+ dispatch(editExperimentConfigurationQuery(courseId, configuration)).then(
+ (result) => {
+ if (result) {
+ callbackToClose();
+ }
+ },
+ );
+ },
+ handleDelete: (configurationId) => {
+ dispatch(deleteExperimentConfigurationQuery(courseId, configurationId));
+ },
+ };
+
+ useEffect(() => {
+ dispatch(fetchGroupConfigurationsQuery(courseId));
+ }, [courseId]);
+
+ return {
+ isLoading: loadingStatus === RequestStatus.IN_PROGRESS,
+ savingStatus,
+ contentGroupActions,
+ experimentConfigurationActions,
+ groupConfigurations,
+ isShowProcessingNotification,
+ processingNotificationTitle,
+ handleInternetConnectionFailed,
+ };
+};
+
+// eslint-disable-next-line import/prefer-default-export
+export { useGroupConfigurations };
diff --git a/src/group-configurations/hooks.test.jsx b/src/group-configurations/hooks.test.jsx
new file mode 100644
index 0000000000..87ed09f6c8
--- /dev/null
+++ b/src/group-configurations/hooks.test.jsx
@@ -0,0 +1,109 @@
+import MockAdapter from 'axios-mock-adapter';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { initializeMockApp } from '@edx/frontend-platform';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import { renderHook } from '@testing-library/react-hooks';
+import { Provider, useDispatch } from 'react-redux';
+
+import { RequestStatus } from '../data/constants';
+import initializeStore from '../store';
+import { getContentStoreApiUrl } from './data/api';
+import {
+ createContentGroupQuery,
+ createExperimentConfigurationQuery,
+ deleteContentGroupQuery,
+ deleteExperimentConfigurationQuery,
+ editContentGroupQuery,
+ editExperimentConfigurationQuery,
+} from './data/thunk';
+import { groupConfigurationResponseMock } from './__mocks__';
+import { useGroupConfigurations } from './hooks';
+import { updateSavingStatuses } from './data/slice';
+
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useDispatch: jest.fn(),
+}));
+
+jest.mock('./data/thunk', () => ({
+ ...jest.requireActual('./data/thunk'),
+ createContentGroupQuery: jest.fn().mockResolvedValue(true),
+ createExperimentConfigurationQuery: jest.fn().mockResolvedValue(true),
+ deleteContentGroupQuery: jest.fn().mockResolvedValue(true),
+ deleteExperimentConfigurationQuery: jest.fn().mockResolvedValue(true),
+ editContentGroupQuery: jest.fn().mockResolvedValue(true),
+ editExperimentConfigurationQuery: jest.fn().mockResolvedValue(true),
+ getContentStoreApiUrlQuery: jest.fn().mockResolvedValue(true),
+}));
+
+let axiosMock;
+let store;
+const courseId = 'course-v1:org+101+101';
+const mockObject = {};
+const mockFunc = jest.fn();
+let dispatch;
+
+const wrapper = ({ children }) => (
+
+
+ {children}
+
+
+);
+
+describe('useGroupConfigurations', () => {
+ beforeEach(async () => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ },
+ });
+
+ store = initializeStore();
+ axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ axiosMock
+ .onGet(getContentStoreApiUrl(courseId))
+ .reply(200, groupConfigurationResponseMock);
+ dispatch = jest.fn().mockImplementation(() => Promise.resolve(true));
+ useDispatch.mockReturnValue(dispatch);
+ });
+
+ it('successfully dispatches handleInternetConnectionFailed', async () => {
+ const { result } = renderHook(() => useGroupConfigurations(courseId), { wrapper });
+ result.current.handleInternetConnectionFailed();
+ expect(dispatch).toHaveBeenCalledWith(updateSavingStatuses({ status: RequestStatus.FAILED }));
+ });
+ it('successfully dispatches handleCreate for group configuration', async () => {
+ const { result } = renderHook(() => useGroupConfigurations(courseId), { wrapper });
+ result.current.contentGroupActions.handleCreate(mockObject, mockFunc);
+ expect(dispatch).toHaveBeenCalledWith(createContentGroupQuery(courseId, mockObject));
+ });
+ it('successfully dispatches handleEdit for group configuration', async () => {
+ const { result } = renderHook(() => useGroupConfigurations(courseId), { wrapper });
+ result.current.contentGroupActions.handleEdit(mockObject, mockFunc);
+ expect(dispatch).toHaveBeenCalledWith(editContentGroupQuery(courseId, mockObject));
+ });
+ it('successfully dispatches handleDelete for group configuration', async () => {
+ const { result } = renderHook(() => useGroupConfigurations(courseId), { wrapper });
+ result.current.contentGroupActions.handleDelete(1, 1);
+ expect(dispatch).toHaveBeenCalledWith(deleteContentGroupQuery(courseId, 1, 1));
+ });
+ it('successfully dispatches handleCreate for experiment group', async () => {
+ const { result } = renderHook(() => useGroupConfigurations(courseId), { wrapper });
+ result.current.experimentConfigurationActions.handleCreate(mockObject, mockFunc);
+ expect(dispatch).toHaveBeenCalledWith(createExperimentConfigurationQuery(courseId, mockObject));
+ });
+ it('successfully dispatches handleEdit for experiment group', async () => {
+ const { result } = renderHook(() => useGroupConfigurations(courseId), { wrapper });
+ result.current.experimentConfigurationActions.handleEdit(mockObject, mockFunc);
+ expect(dispatch).toHaveBeenCalledWith(editExperimentConfigurationQuery(courseId, mockObject));
+ });
+ it('successfully dispatches handleDelete for experiment group', async () => {
+ const { result } = renderHook(() => useGroupConfigurations(courseId), { wrapper });
+ result.current.experimentConfigurationActions.handleDelete(mockObject, 1);
+ expect(dispatch).toHaveBeenCalledWith(deleteExperimentConfigurationQuery(courseId, 1));
+ });
+});
diff --git a/src/group-configurations/index.jsx b/src/group-configurations/index.jsx
new file mode 100644
index 0000000000..9c59251312
--- /dev/null
+++ b/src/group-configurations/index.jsx
@@ -0,0 +1,123 @@
+import PropTypes from 'prop-types';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import {
+ Container, Layout, Stack, Row,
+} from '@openedx/paragon';
+
+import { RequestStatus } from '../data/constants';
+import { LoadingSpinner } from '../generic/Loading';
+import { useModel } from '../generic/model-store';
+import SubHeader from '../generic/sub-header/SubHeader';
+import getPageHeadTitle from '../generic/utils';
+import ProcessingNotification from '../generic/processing-notification';
+import InternetConnectionAlert from '../generic/internet-connection-alert';
+import messages from './messages';
+import ContentGroupsSection from './content-groups-section';
+import ExperimentConfigurationsSection from './experiment-configurations-section';
+import EnrollmentTrackGroupsSection from './enrollment-track-groups-section';
+import GroupConfigurationSidebar from './group-configuration-sidebar';
+import { useGroupConfigurations } from './hooks';
+
+const GroupConfigurations = ({ courseId }) => {
+ const { formatMessage } = useIntl();
+ const courseDetails = useModel('courseDetails', courseId);
+ const {
+ isLoading,
+ savingStatus,
+ contentGroupActions,
+ experimentConfigurationActions,
+ processingNotificationTitle,
+ isShowProcessingNotification,
+ groupConfigurations: {
+ allGroupConfigurations,
+ shouldShowEnrollmentTrack,
+ shouldShowExperimentGroups,
+ experimentGroupConfigurations,
+ },
+ handleInternetConnectionFailed,
+ } = useGroupConfigurations(courseId);
+
+ document.title = getPageHeadTitle(
+ courseDetails?.name,
+ formatMessage(messages.headingTitle),
+ );
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ const enrollmentTrackGroup = shouldShowEnrollmentTrack
+ ? allGroupConfigurations[0]
+ : null;
+ const contentGroup = allGroupConfigurations?.[shouldShowEnrollmentTrack ? 1 : 0];
+
+ return (
+ <>
+
+
+
+
+
+
+ {!!enrollmentTrackGroup && (
+
+ )}
+ {!!contentGroup && (
+
+ )}
+ {shouldShowExperimentGroups && (
+
+ )}
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+GroupConfigurations.propTypes = {
+ courseId: PropTypes.string.isRequired,
+};
+
+export default GroupConfigurations;
diff --git a/src/group-configurations/messages.js b/src/group-configurations/messages.js
new file mode 100644
index 0000000000..01e0eacefd
--- /dev/null
+++ b/src/group-configurations/messages.js
@@ -0,0 +1,31 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ headingTitle: {
+ id: 'course-authoring.group-configurations.heading-title',
+ defaultMessage: 'Group configurations',
+ description: 'Title for the heading of the group configurations section.',
+ },
+ headingSubtitle: {
+ id: 'course-authoring.group-configurations.heading-sub-title',
+ defaultMessage: 'Settings',
+ description: 'Subtitle for the heading of the group configurations section.',
+ },
+ containsGroups: {
+ id: 'course-authoring.group-configurations.container.contains-groups',
+ defaultMessage: 'Contains {len, plural, one {group} other {groups}}',
+ description: 'Message indicating the number of groups contained within a container.',
+ },
+ notInUse: {
+ id: 'course-authoring.group-configurations.container.not-in-use',
+ defaultMessage: 'Not in use',
+ description: 'Message indicating that the group configurations are not currently in use.',
+ },
+ usedInLocations: {
+ id: 'course-authoring.group-configurations.container.used-in-locations',
+ defaultMessage: 'Used in {len, plural, one {location} other {locations}}',
+ description: 'Message indicating the number of locations where the group configurations are used.',
+ },
+});
+
+export default messages;
diff --git a/src/group-configurations/utils.js b/src/group-configurations/utils.js
new file mode 100644
index 0000000000..d701081fcc
--- /dev/null
+++ b/src/group-configurations/utils.js
@@ -0,0 +1,53 @@
+import { getConfig } from '@edx/frontend-platform';
+
+import messages from './messages';
+
+/**
+ * Formats the given URL to a unit page URL.
+ * @param {string} url - The original part of URL.
+ * @returns {string} - The formatted unit page URL.
+ */
+const formatUrlToUnitPage = (url) => new URL(url, getConfig().STUDIO_BASE_URL).href;
+
+/**
+ * Retrieves a list of group count based on the number of items.
+ * @param {Array} items - The array of items to count.
+ * @param {function} formatMessage - The function for formatting localized messages.
+ * @returns {Array} - List of group count.
+ */
+const getGroupsCountMessage = (items, formatMessage) => {
+ if (!items?.length) {
+ return [];
+ }
+
+ return [formatMessage(messages.containsGroups, { len: items.length })];
+};
+
+/**
+ * Retrieves a list of usage count based on the number of items.
+ * @param {Array} items - The array of items to count.
+ * @param {function} formatMessage - The function for formatting localized messages.
+ * @returns {Array} - List of usage count.
+ */
+const getUsageCountMessage = (items, formatMessage) => {
+ if (!items?.length) {
+ return [formatMessage(messages.notInUse)];
+ }
+
+ return [formatMessage(messages.usedInLocations, { len: items.length })];
+};
+
+/**
+ * Retrieves a combined list of badge messages based on usage and group information.
+ * @param {Array} usage - The array of items indicating usage.
+ * @param {Object} group - The group information.
+ * @param {boolean} isExperiment - Flag indicating whether it is an experiment group configurations.
+ * @param {function} formatMessage - The function for formatting localized messages.
+ * @returns {Array} - Combined list of badges.
+ */
+const getCombinedBadgeList = (usage, group, isExperiment, formatMessage) => [
+ ...(isExperiment ? getGroupsCountMessage(group.groups, formatMessage) : []),
+ ...getUsageCountMessage(usage, formatMessage),
+];
+
+export { formatUrlToUnitPage, getCombinedBadgeList };
diff --git a/src/hooks.js b/src/hooks.js
index 180d89fc6a..73597e3ef6 100644
--- a/src/hooks.js
+++ b/src/hooks.js
@@ -1,19 +1,23 @@
-import { useEffect } from 'react';
+import { useEffect, useState } from 'react';
import { history } from '@edx/frontend-platform';
export const useScrollToHashElement = ({ isLoading }) => {
+ const [elementWithHash, setElementWithHash] = useState(null);
+
useEffect(() => {
- const currentHash = window.location.hash;
+ const currentHash = window.location.hash.substring(1);
if (currentHash) {
- const element = document.querySelector(currentHash);
-
+ const element = document.getElementById(currentHash);
if (element) {
element.scrollIntoView();
history.replace({ hash: '' });
}
+ setElementWithHash(currentHash);
}
}, [isLoading]);
+
+ return { elementWithHash };
};
export const useEscapeClick = ({ onEscape, dependency }) => {
diff --git a/src/index.jsx b/src/index.jsx
index f717df4c02..1f42b42bdf 100755
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -8,7 +8,7 @@ import { AppProvider, ErrorPage } from '@edx/frontend-platform/react';
import React, { useEffect } from 'react';
import ReactDOM from 'react-dom';
import {
- Navigate, Route, createRoutesFromElements, createBrowserRouter, RouterProvider,
+ Route, createRoutesFromElements, createBrowserRouter, RouterProvider,
} from 'react-router-dom';
import {
QueryClient,
@@ -59,8 +59,6 @@ const App = () => {
)}
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && (
<>
- {/* TODO: remove this redirect once Studio's link is updated */}
- } />
}>
} />