feat: [FC-0044] Unit page - Manage access modal (unit & xblocks) (#901)

* feat: [FC-0044] Unit page - Manage access modal (unit & xblocks)

* fix: add message description
This commit is contained in:
Ihor Romaniuk
2024-04-22 17:13:16 +02:00
committed by GitHub
parent 1834655399
commit 6ec44b5f41
37 changed files with 1202 additions and 330 deletions

View File

@@ -49,3 +49,10 @@ export const DECODED_ROUTES = {
'/container/:blockId',
],
};
export const COURSE_BLOCK_NAMES = ({
chapter: { id: 'chapter', name: 'Section' },
sequential: { id: 'sequential', name: 'Subsection' },
vertical: { id: 'vertical', name: 'Unit' },
component: { id: 'component', name: 'Component' },
});

View File

@@ -29,9 +29,10 @@ import SubHeader from '../generic/sub-header/SubHeader';
import ProcessingNotification from '../generic/processing-notification';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import DeleteModal from '../generic/delete-modal/DeleteModal';
import ConfigureModal from '../generic/configure-modal/ConfigureModal';
import AlertMessage from '../generic/alert-message';
import getPageHeadTitle from '../generic/utils';
import { getCurrentItem } from './data/selectors';
import { getCurrentItem, getProctoredExamsFlag } from './data/selectors';
import { COURSE_BLOCK_NAMES } from './constants';
import HeaderNavigations from './header-navigations/HeaderNavigations';
import OutlineSideBar from './outline-sidebar/OutlineSidebar';
@@ -43,7 +44,6 @@ import UnitCard from './unit-card/UnitCard';
import HighlightsModal from './highlights-modal/HighlightsModal';
import EmptyPlaceholder from './empty-placeholder/EmptyPlaceholder';
import PublishModal from './publish-modal/PublishModal';
import ConfigureModal from './configure-modal/ConfigureModal';
import PageAlerts from './page-alerts/PageAlerts';
import DraggableList from './drag-helper/DraggableList';
import {
@@ -129,8 +129,10 @@ const CourseOutline = ({ courseId }) => {
title: processingNotificationTitle,
} = useSelector(getProcessingNotification);
const { category } = useSelector(getCurrentItem);
const deleteCategory = COURSE_BLOCK_NAMES[category]?.name.toLowerCase();
const currentItemData = useSelector(getCurrentItem);
const deleteCategory = COURSE_BLOCK_NAMES[currentItemData.category]?.name.toLowerCase();
const enableProctoredExams = useSelector(getProctoredExamsFlag);
/**
* Move section to new index
@@ -431,6 +433,8 @@ const CourseOutline = ({ courseId }) => {
isOpen={isConfigureModalOpen}
onClose={handleConfigureModalClose}
onConfigureSubmit={handleConfigureItemSubmit}
currentItemData={currentItemData}
enableProctoredExams={enableProctoredExams}
/>
<DeleteModal
category={deleteCategory}

View File

@@ -7,7 +7,6 @@
@import "./empty-placeholder/EmptyPlaceholder";
@import "./highlights-modal/HighlightsModal";
@import "./publish-modal/PublishModal";
@import "./configure-modal/ConfigureModal";
@import "./drag-helper/SortableItem";
@import "./xblock-status/XBlockStatus";
@import "./paste-button/PasteButton";

View File

@@ -40,12 +40,12 @@ import {
import { executeThunk } from '../utils';
import { COURSE_BLOCK_NAMES, VIDEO_SHARING_OPTIONS } from './constants';
import CourseOutline from './CourseOutline';
import messages from './messages';
import configureModalMessages from '../generic/configure-modal/messages';
import headerMessages from './header-navigations/messages';
import cardHeaderMessages from './card-header/messages';
import enableHighlightsModalMessages from './enable-highlights-modal/messages';
import statusBarMessages from './status-bar/messages';
import configureModalMessages from './configure-modal/messages';
import pasteButtonMessages from './paste-button/messages';
import subsectionMessages from './subsection-card/messages';
import pageAlertMessages from './page-alerts/messages';
@@ -55,6 +55,7 @@ import {
moveSubsection,
moveUnit,
} from './drag-helper/utils';
import messages from './messages';
let axiosMock;
let store;

View File

@@ -48,6 +48,7 @@ const CourseUnit = ({ courseId }) => {
handleTitleEdit,
handleInternetConnectionFailed,
handleCreateNewCourseXBlock,
handleConfigureSubmit,
courseVerticalChildren,
} = useCourseUnit({ courseId, blockId });
@@ -85,6 +86,7 @@ const CourseUnit = ({ courseId }) => {
isTitleEditFormOpen={isTitleEditFormOpen}
handleTitleEdit={handleTitleEdit}
handleTitleEditSubmit={handleTitleEditSubmit}
handleConfigureSubmit={handleConfigureSubmit}
/>
)}
breadcrumbs={(
@@ -119,16 +121,20 @@ const CourseUnit = ({ courseId }) => {
)}
<Stack gap={4} className="mb-4">
{courseVerticalChildren.children.map(({
name, blockId: id, blockType: type, shouldScroll,
name, blockId: id, blockType: type, shouldScroll, userPartitionInfo, validationMessages,
}) => (
<CourseXBlock
id={id}
key={id}
title={name}
type={type}
blockId={blockId}
validationMessages={validationMessages}
shouldScroll={shouldScroll}
unitXBlockActions={unitXBlockActions}
handleConfigureSubmit={handleConfigureSubmit}
data-testid="course-xblock"
userPartitionInfo={userPartitionInfo}
/>
))}
</Stack>

View File

@@ -3,3 +3,4 @@
@import "./add-component/AddComponent";
@import "./course-xblock/CourseXBlock";
@import "./sidebar/Sidebar";
@import "./header-title/HeaderTitle";

View File

@@ -38,13 +38,14 @@ import courseSequenceMessages from './course-sequence/messages';
import sidebarMessages from './sidebar/messages';
import { extractCourseUnitId } from './sidebar/utils';
import CourseUnit from './CourseUnit';
import messages from './messages';
import deleteModalMessages from '../generic/delete-modal/messages';
import configureModalMessages from '../generic/configure-modal/messages';
import courseXBlockMessages from './course-xblock/messages';
import addComponentMessages from './add-component/messages';
import { PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants';
import { getContentTaxonomyTagsApiUrl, getContentTaxonomyTagsCountApiUrl } from '../content-tags-drawer/data/api';
import messages from './messages';
let axiosMock;
let store;
@@ -571,6 +572,7 @@ describe('<CourseUnit />', () => {
name: 'New Cloned XBlock',
block_id: '1234567890',
block_type: 'drag-and-drop-v2',
user_partition_info: {},
},
],
});
@@ -594,7 +596,7 @@ describe('<CourseUnit />', () => {
});
});
it('should toggle visibility and update course unit state accordingly', async () => {
it('should toggle visibility from sidebar and update course unit state accordingly', async () => {
const { getByRole, getByTestId } = render(<RootWrapper />);
let courseUnitSidebar;
let draftUnpublishedChangesHeading;
@@ -617,7 +619,7 @@ describe('<CourseUnit />', () => {
axiosMock
.onPost(getXBlockBaseApiUrl(blockId), {
publish: PUBLISH_TYPES.republish,
metadata: { visible_to_staff_only: true },
metadata: { visible_to_staff_only: true, group_access: null },
})
.reply(200, { dummy: 'value' });
axiosMock
@@ -654,7 +656,7 @@ describe('<CourseUnit />', () => {
axiosMock
.onPost(getXBlockBaseApiUrl(blockId), {
publish: PUBLISH_TYPES.republish,
metadata: { visible_to_staff_only: null },
metadata: { visible_to_staff_only: null, group_access: null },
})
.reply(200, { dummy: 'value' });
axiosMock
@@ -942,4 +944,73 @@ describe('<CourseUnit />', () => {
.replace('{sectionName}', courseUnitIndexMock.release_date_from),
)).toBeInTheDocument();
});
it('should toggle visibility from header configure modal and update course unit state accordingly', async () => {
const { getByRole, getByTestId } = render(<RootWrapper />);
let courseUnitSidebar;
let sidebarVisibilityCheckbox;
let modalVisibilityCheckbox;
let configureModal;
let restrictAccessSelect;
await waitFor(() => {
courseUnitSidebar = getByTestId('course-unit-sidebar');
sidebarVisibilityCheckbox = within(courseUnitSidebar)
.getByLabelText(sidebarMessages.visibilityCheckboxTitle.defaultMessage);
expect(sidebarVisibilityCheckbox).not.toBeChecked();
const headerConfigureBtn = getByRole('button', { name: /settings/i });
expect(headerConfigureBtn).toBeInTheDocument();
userEvent.click(headerConfigureBtn);
configureModal = getByTestId('configure-modal');
restrictAccessSelect = within(configureModal)
.getByRole('combobox', { name: configureModalMessages.restrictAccessTo.defaultMessage });
expect(within(configureModal)
.getByText(configureModalMessages.unitVisibility.defaultMessage)).toBeInTheDocument();
expect(within(configureModal)
.getByText(configureModalMessages.restrictAccessTo.defaultMessage)).toBeInTheDocument();
expect(restrictAccessSelect).toBeInTheDocument();
expect(restrictAccessSelect).toHaveValue('-1');
modalVisibilityCheckbox = within(configureModal)
.getByRole('checkbox', { name: configureModalMessages.hideFromLearners.defaultMessage });
expect(modalVisibilityCheckbox).not.toBeChecked();
userEvent.click(modalVisibilityCheckbox);
expect(modalVisibilityCheckbox).toBeChecked();
userEvent.selectOptions(restrictAccessSelect, '0');
const [, group1Checkbox] = within(configureModal).getAllByRole('checkbox');
userEvent.click(group1Checkbox);
expect(group1Checkbox).toBeChecked();
});
axiosMock
.onPost(getXBlockBaseApiUrl(courseUnitIndexMock.id), {
publish: null,
metadata: { visible_to_staff_only: true, group_access: { 50: [2] } },
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.replyOnce(200, {
...courseUnitIndexMock,
visibility_state: UNIT_VISIBILITY_STATES.staffOnly,
has_explicit_staff_lock: true,
});
const modalSaveBtn = within(configureModal)
.getByRole('button', { name: configureModalMessages.saveButton.defaultMessage });
userEvent.click(modalSaveBtn);
await waitFor(() => {
expect(sidebarVisibilityCheckbox).toBeChecked();
expect(within(courseUnitSidebar)
.getByText(sidebarMessages.sidebarTitleVisibleToStaffOnly.defaultMessage)).toBeInTheDocument();
expect(within(courseUnitSidebar)
.getByText(sidebarMessages.visibilityStaffOnlyTitle.defaultMessage)).toBeInTheDocument();
});
});
});

View File

@@ -2,14 +2,146 @@ module.exports = {
children: [
{
name: 'Discussion',
block_id: 'block-v1:OpenedX+L153+3T2023+type@discussion+block@fecd20842dd24f50bdc06643e791b013',
block_id: 'block-v1:OpenedX+L153+3T2023+type@discussion+block@5a28279f24344723a96b1268d3b7cfc0',
block_type: 'discussion',
actions: {
can_copy: true,
can_duplicate: true,
can_move: true,
can_manage_access: true,
can_delete: true,
},
user_partition_info: {
selectable_partitions: [
{
id: 970807507,
name: 'Content Groups',
scheme: 'cohort',
groups: [
{
id: 1959537066,
name: 'Group 1',
selected: false,
deleted: false,
},
{
id: 108068059,
name: 'Group 2',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
user_partitions: [
{
id: 970807507,
name: 'Content Groups',
scheme: 'cohort',
groups: [
{
id: 1959537066,
name: 'Group 1',
selected: false,
deleted: false,
},
{
id: 108068059,
name: 'Group 2',
selected: false,
deleted: false,
},
],
},
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
},
{
name: 'Drag and Drop',
block_id: 'block-v1:OpenedX+L153+3T2023+type@drag-and-drop-v2+block@b33cf1f6df4c41639659bc91132eeb02',
block_type: 'drag-and-drop-v2',
actions: {
can_copy: true,
can_duplicate: true,
can_move: true,
can_manage_access: true,
can_delete: true,
},
user_partition_info: {
selectable_partitions: [
{
id: 970807507,
name: 'Content Groups',
scheme: 'cohort',
groups: [
{
id: 1959537066,
name: 'Group 1',
selected: false,
deleted: false,
},
{
id: 108068059,
name: 'Group 2',
selected: false,
deleted: false,
},
],
},
],
selected_partition_index: -1,
selected_groups_label: '',
},
user_partitions: [
{
id: 970807507,
name: 'Content Groups',
scheme: 'cohort',
groups: [
{
id: 1959537066,
name: 'Group 1',
selected: false,
deleted: false,
},
{
id: 108068059,
name: 'Group 2',
selected: false,
deleted: false,
},
],
},
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
},
],
is_published: false,
isPublished: false,
};

View File

@@ -9,21 +9,37 @@ import { useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import DeleteModal from '../../generic/delete-modal/DeleteModal';
import ConfigureModal from '../../generic/configure-modal/ConfigureModal';
import { scrollToElement } from '../../course-outline/utils';
import { COURSE_BLOCK_NAMES } from '../../constants';
import { getCourseId } from '../data/selectors';
import { COMPONENT_TYPES } from '../constants';
import XBlockMessages from './xblock-messages/XBlockMessages';
import messages from './messages';
const CourseXBlock = ({
id, title, type, unitXBlockActions, shouldScroll, ...props
id, title, type, unitXBlockActions, shouldScroll, userPartitionInfo,
handleConfigureSubmit, validationMessages, ...props
}) => {
const courseXBlockElementRef = useRef(null);
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
const navigate = useNavigate();
const courseId = useSelector(getCourseId);
const intl = useIntl();
const onXBlockDelete = () => {
const visibilityMessage = userPartitionInfo.selectedGroupsLabel
? intl.formatMessage(messages.visibilityMessage, { selectedGroupsLabel: userPartitionInfo.selectedGroupsLabel })
: null;
const currentItemData = {
category: COURSE_BLOCK_NAMES.component.id,
displayName: title,
userPartitionInfo,
showCorrectness: 'always',
};
const onDeleteSubmit = () => {
unitXBlockActions.handleDelete(id);
closeDeleteModal();
};
@@ -39,6 +55,10 @@ const CourseXBlock = ({
}
};
const onConfigureSubmit = (...arg) => {
handleConfigureSubmit(id, ...arg, closeConfigureModal);
};
useEffect(() => {
// if this item has been newly added, scroll to it.
if (courseXBlockElementRef.current && shouldScroll) {
@@ -51,6 +71,7 @@ const CourseXBlock = ({
<Card className="mb-1">
<Card.Header
title={title}
subtitle={visibilityMessage}
actions={(
<ActionRow>
<IconButton
@@ -78,7 +99,7 @@ const CourseXBlock = ({
<Dropdown.Item>
{intl.formatMessage(messages.blockLabelButtonMove)}
</Dropdown.Item>
<Dropdown.Item>
<Dropdown.Item onClick={openConfigureModal}>
{intl.formatMessage(messages.blockLabelButtonManageAccess)}
</Dropdown.Item>
<Dropdown.Item onClick={openDeleteModal}>
@@ -90,13 +111,21 @@ const CourseXBlock = ({
category="component"
isOpen={isDeleteModalOpen}
close={closeDeleteModal}
onDeleteSubmit={onXBlockDelete}
onDeleteSubmit={onDeleteSubmit}
/>
<ConfigureModal
isXBlockComponent
isOpen={isConfigureModalOpen}
onClose={closeConfigureModal}
onConfigureSubmit={onConfigureSubmit}
currentItemData={currentItemData}
/>
</ActionRow>
)}
size="md"
/>
<Card.Section>
<XBlockMessages validationMessages={validationMessages} />
<div className="w-100 bg-gray-100" style={{ height: 200 }} data-block-id={id} />
</Card.Section>
</Card>
@@ -105,6 +134,7 @@ const CourseXBlock = ({
};
CourseXBlock.defaultProps = {
validationMessages: [],
shouldScroll: false,
};
@@ -113,10 +143,30 @@ CourseXBlock.propTypes = {
title: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
shouldScroll: PropTypes.bool,
validationMessages: PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.string,
text: PropTypes.string,
})),
unitXBlockActions: PropTypes.shape({
handleDelete: PropTypes.func,
handleDuplicate: PropTypes.func,
}).isRequired,
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,
}).isRequired,
handleConfigureSubmit: PropTypes.func.isRequired,
};
export default CourseXBlock;

View File

@@ -1,15 +1,32 @@
.course-unit {
.pgn__card .pgn__card-header {
border-bottom: 1px solid $light-400;
padding-bottom: map-get($spacers, 2);
.course-unit__xblocks {
.pgn__card-header {
display: flex;
justify-content: space-between;
border-bottom: 1px solid $light-400;
padding-bottom: map-get($spacers, 2);
.pgn__card-header-content {
margin-top: map-get($spacers, 3\.5);
&:not(:has(.pgn__card-header-subtitle-md)) {
align-items: center;
}
}
.btn-icon .btn-icon__icon {
width: 1.5rem;
height: 1.5rem;
.pgn__card-header-subtitle-md {
margin-top: 0;
font-size: $font-size-sm;
}
.pgn__card-header-title-md {
font: 700 1.375rem/1.75rem $font-family-sans-serif;
color: $black;
}
.pgn__card-section {
padding: map-get($spacers, 3\.5) 0;
}
}
.unit-iframe__wrapper .alert-danger {
margin-bottom: 0;
}
}

View File

@@ -1,18 +1,27 @@
import { render, waitFor } from '@testing-library/react';
import {
render, waitFor, within,
} from '@testing-library/react';
import { useSelector } from 'react-redux';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getCourseId } from '../data/selectors';
import { COMPONENT_TYPES } from '../constants';
import { courseVerticalChildrenMock } from '../__mocks__';
import CourseXBlock from './CourseXBlock';
import configureModalMessages from '../../generic/configure-modal/messages';
import deleteModalMessages from '../../generic/delete-modal/messages';
import initializeStore from '../../store';
import { getCourseSectionVerticalApiUrl, getXBlockBaseApiUrl } from '../data/api';
import { fetchCourseSectionVerticalData } from '../data/thunk';
import { executeThunk } from '../../utils';
import { getCourseId } from '../data/selectors';
import { PUBLISH_TYPES, COMPONENT_TYPES } from '../constants';
import { courseSectionVerticalMock, courseVerticalChildrenMock } from '../__mocks__';
import CourseXBlock from './CourseXBlock';
import messages from './messages';
let axiosMock;
let store;
const courseId = '1234';
const blockId = '567890';
@@ -26,6 +35,7 @@ const {
block_type: type,
user_partition_info: userPartitionInfo,
} = courseVerticalChildrenMock.children[0];
const userPartitionInfoFormatted = camelCaseObject(userPartitionInfo);
const unitXBlockActionsMock = {
handleDelete: handleDeleteMock,
handleDuplicate: handleDuplicateMock,
@@ -50,7 +60,7 @@ const renderComponent = (props) => render(
type={type}
blockId={blockId}
unitXBlockActions={unitXBlockActionsMock}
userPartitionInfo={camelCaseObject(userPartitionInfo)}
userPartitionInfo={userPartitionInfoFormatted}
shouldScroll={false}
handleConfigureSubmit={handleConfigureSubmitMock}
{...props}
@@ -76,6 +86,13 @@ describe('<CourseXBlock />', () => {
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getCourseSectionVerticalApiUrl(blockId))
.reply(200, courseSectionVerticalMock);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
});
it('render CourseXBlock component correctly', async () => {
@@ -93,7 +110,6 @@ describe('<CourseXBlock />', () => {
await waitFor(() => {
userEvent.click(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage));
expect(getByRole('button', { name: messages.blockLabelButtonCopy.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.blockLabelButtonDuplicate.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.blockLabelButtonMove.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.blockLabelButtonManageAccess.defaultMessage })).toBeInTheDocument();
@@ -181,6 +197,117 @@ describe('<CourseXBlock />', () => {
userEvent.click(editButton);
expect(mockedUsedNavigate).toHaveBeenCalled();
expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseId}/editor/problem/${id}`);
expect(handleDeleteMock).toHaveBeenCalledWith(id);
});
});
describe('restrict access', () => {
it('opens restrict access modal successfully', async () => {
const {
getByText,
getByLabelText,
findByTestId,
} = renderComponent();
const modalSubtitleText = configureModalMessages.restrictAccessTo.defaultMessage;
const modalCancelBtnText = configureModalMessages.cancelButton.defaultMessage;
const modalSaveBtnText = configureModalMessages.saveButton.defaultMessage;
userEvent.click(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage));
const accessBtn = getByText(messages.blockLabelButtonManageAccess.defaultMessage);
userEvent.click(accessBtn);
const configureModal = await findByTestId('configure-modal');
expect(within(configureModal).getByText(modalSubtitleText)).toBeInTheDocument();
expect(within(configureModal).getByRole('button', { name: modalCancelBtnText })).toBeInTheDocument();
expect(within(configureModal).getByRole('button', { name: modalSaveBtnText })).toBeInTheDocument();
});
it('closes restrict access modal when cancel button is clicked', async () => {
const {
getByText,
getByLabelText,
findByTestId,
} = renderComponent();
userEvent.click(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage));
const accessBtn = getByText(messages.blockLabelButtonManageAccess.defaultMessage);
userEvent.click(accessBtn);
const configureModal = await findByTestId('configure-modal');
expect(configureModal).toBeInTheDocument();
userEvent.click(within(configureModal).getByRole('button', { name: configureModalMessages.saveButton.defaultMessage }));
expect(handleConfigureSubmitMock).not.toHaveBeenCalled();
});
it('handles submit restrict access data when save button is clicked', async () => {
axiosMock
.onPost(getXBlockBaseApiUrl(id), {
publish: PUBLISH_TYPES.republish,
metadata: { visible_to_staff_only: false, group_access: { 970807507: [1959537066] } },
})
.reply(200, { dummy: 'value' });
const {
getByText,
getByLabelText,
findByTestId,
getByRole,
} = renderComponent();
const accessGroupName1 = userPartitionInfoFormatted.selectablePartitions[0].groups[0].name;
const accessGroupName2 = userPartitionInfoFormatted.selectablePartitions[0].groups[1].name;
userEvent.click(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage));
const accessBtn = getByText(messages.blockLabelButtonManageAccess.defaultMessage);
userEvent.click(accessBtn);
const configureModal = await findByTestId('configure-modal');
expect(configureModal).toBeInTheDocument();
expect(within(configureModal).queryByText(accessGroupName1)).not.toBeInTheDocument();
expect(within(configureModal).queryByText(accessGroupName2)).not.toBeInTheDocument();
const restrictAccessSelect = getByRole('combobox', {
name: configureModalMessages.restrictAccessTo.defaultMessage,
});
userEvent.selectOptions(restrictAccessSelect, '0');
// eslint-disable-next-line array-callback-return
userPartitionInfoFormatted.selectablePartitions[0].groups.map((group) => {
expect(within(configureModal).getByRole('checkbox', { name: group.name })).not.toBeChecked();
expect(within(configureModal).queryByText(group.name)).toBeInTheDocument();
});
const group1Checkbox = within(configureModal).getByRole('checkbox', { name: accessGroupName1 });
userEvent.click(group1Checkbox);
expect(group1Checkbox).toBeChecked();
const saveModalBtnText = within(configureModal).getByRole('button', {
name: configureModalMessages.saveButton.defaultMessage,
});
expect(saveModalBtnText).toBeInTheDocument();
userEvent.click(saveModalBtnText);
await waitFor(() => {
expect(handleConfigureSubmitMock).toHaveBeenCalledTimes(1);
});
});
});
it('displays a visibility message if item has accessible restrictions', async () => {
const { getByText } = renderComponent(
{
userPartitionInfo: {
...userPartitionInfoFormatted,
selectedGroupsLabel: 'Visibility group 1',
},
},
);
await waitFor(() => {
const visibilityMessage = messages.visibilityMessage.defaultMessage
.replace('{selectedGroupsLabel}', 'Visibility group 1');
expect(getByText(visibilityMessage)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,5 @@
// eslint-disable-next-line import/prefer-default-export
export const MESSAGE_ERROR_TYPES = {
error: 'error',
warning: 'warning',
};

View File

@@ -4,30 +4,47 @@ const messages = defineMessages({
blockAltButtonEdit: {
id: 'course-authoring.course-unit.xblock.button.edit.alt',
defaultMessage: 'Edit',
description: 'The xblock edit button text',
},
blockActionsDropdownAlt: {
id: 'course-authoring.course-unit.xblock.button.actions.alt',
defaultMessage: 'Actions',
description: 'The xblock three dots dropdown alt text',
},
blockLabelButtonCopy: {
id: 'course-authoring.course-unit.xblock.button.copy.label',
defaultMessage: 'Copy',
description: 'The xblock copy button text',
},
blockLabelButtonDuplicate: {
id: 'course-authoring.course-unit.xblock.button.duplicate.label',
defaultMessage: 'Duplicate',
description: 'The xblock duplicate button text',
},
blockLabelButtonMove: {
id: 'course-authoring.course-unit.xblock.button.move.label',
defaultMessage: 'Move',
description: 'The xblock move button text',
},
blockLabelButtonManageAccess: {
id: 'course-authoring.course-unit.xblock.button.manageAccess.label',
defaultMessage: 'Manage access',
description: 'The xblock manage access button text',
},
blockLabelButtonDelete: {
id: 'course-authoring.course-unit.xblock.button.delete.label',
defaultMessage: 'Delete',
description: 'The xblock delete button text',
},
visibilityMessage: {
id: 'course-authoring.course-unit.xblock.visibility.message',
defaultMessage: 'Access restricted to: {selectedGroupsLabel}',
description: 'Group visibility accessibility text for xblock',
},
validationSummary: {
id: 'course-authoring.course-unit.xblock.validation.summary',
defaultMessage: 'This component has validation issues.',
description: 'The alert text of the visibility validation issues',
},
});

View File

@@ -0,0 +1,49 @@
import PropTypes from 'prop-types';
import { Alert } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Info as InfoIcon, WarningFilled as WarningIcon } from '@openedx/paragon/icons';
import messages from '../messages';
import { MESSAGE_ERROR_TYPES } from '../constants';
import { getMessagesBlockType } from './utils';
const XBlockMessages = ({ validationMessages }) => {
const intl = useIntl();
const type = getMessagesBlockType(validationMessages);
const { warning } = MESSAGE_ERROR_TYPES;
const alertVariant = type === warning ? 'warning' : 'danger';
const alertIcon = type === warning ? WarningIcon : InfoIcon;
if (!validationMessages.length) {
return null;
}
return (
<Alert
variant={alertVariant}
icon={alertIcon}
>
<Alert.Heading>
{intl.formatMessage(messages.validationSummary)}
</Alert.Heading>
<ul>
{validationMessages.map(({ text }) => (
<li key={text}>{text}</li>
))}
</ul>
</Alert>
);
};
XBlockMessages.defaultProps = {
validationMessages: [],
};
XBlockMessages.propTypes = {
validationMessages: PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.string,
text: PropTypes.string,
})),
};
export default XBlockMessages;

View File

@@ -0,0 +1,55 @@
import { render } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import messages from '../messages';
import XBlockMessages from './XBlockMessages';
const renderComponent = (props) => render(
<IntlProvider locale="en">
<XBlockMessages
{...props}
/>
</IntlProvider>,
);
describe('<XBlockMessages />', () => {
it('renders without errors', () => {
renderComponent({ validationMessages: [] });
});
it('does not render anything when there are no errors', () => {
const { container } = renderComponent({ validationMessages: [] });
expect(container.firstChild).toBeNull();
});
it('renders a warning Alert when there are warning errors', () => {
const validationMessages = [{ type: 'warning', text: 'This is a warning' }];
const { getByText } = renderComponent({ validationMessages });
expect(getByText('This is a warning')).toBeInTheDocument();
expect(getByText(messages.validationSummary.defaultMessage)).toBeInTheDocument();
});
it('renders a danger Alert when there are danger errors', () => {
const validationMessages = [{ type: 'danger', text: 'This is a danger' }];
const { getByText } = renderComponent({ validationMessages });
expect(getByText('This is a danger')).toBeInTheDocument();
expect(getByText(messages.validationSummary.defaultMessage)).toBeInTheDocument();
});
it('renders multiple error messages in a list', () => {
const validationMessages = [
{ type: 'warning', text: 'Warning 1' },
{ type: 'danger', text: 'Danger 1' },
{ type: 'danger', text: 'Danger 2' },
];
const { getByText } = renderComponent({ validationMessages });
expect(getByText('Warning 1')).toBeInTheDocument();
expect(getByText('Danger 1')).toBeInTheDocument();
expect(getByText('Danger 2')).toBeInTheDocument();
expect(getByText(messages.validationSummary.defaultMessage)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,16 @@
import { MESSAGE_ERROR_TYPES } from '../constants';
/**
* Determines the block type based on the types of messages in the given array.
* @param {Array} messages - An array of message objects.
* @param {Object[]} messages.type - The type of each message (e.g., MESSAGE_ERROR_TYPES.error).
* @returns {string} - The block type determined by the messages (e.g., 'warning' or 'error').
*/
// eslint-disable-next-line import/prefer-default-export
export const getMessagesBlockType = (messages) => {
let type = MESSAGE_ERROR_TYPES.warning;
if (messages.some((message) => message.type === MESSAGE_ERROR_TYPES.error)) {
type = MESSAGE_ERROR_TYPES.error;
}
return type;
};

View File

@@ -0,0 +1,44 @@
import { MESSAGE_ERROR_TYPES } from '../constants';
import { getMessagesBlockType } from './utils';
describe('xblock-messages utils', () => {
describe('getMessagesBlockType', () => {
it('returns "warning" when there are no error messages', () => {
const messages = [
{ type: MESSAGE_ERROR_TYPES.warning, text: 'This is a warning' },
{ type: MESSAGE_ERROR_TYPES.warning, text: 'Another warning' },
];
const result = getMessagesBlockType(messages);
expect(result).toBe(MESSAGE_ERROR_TYPES.warning);
});
it('returns "error" when there is at least one error message', () => {
const messages = [
{ type: MESSAGE_ERROR_TYPES.warning, text: 'This is a warning' },
{ type: MESSAGE_ERROR_TYPES.error, text: 'This is an error' },
{ type: MESSAGE_ERROR_TYPES.warning, text: 'Another warning' },
];
const result = getMessagesBlockType(messages);
expect(result).toBe(MESSAGE_ERROR_TYPES.error);
});
it('returns "error" when there are only error messages', () => {
const messages = [
{ type: MESSAGE_ERROR_TYPES.error, text: 'This is an error' },
{ type: MESSAGE_ERROR_TYPES.error, text: 'Another error' },
];
const result = getMessagesBlockType(messages);
expect(result).toBe(MESSAGE_ERROR_TYPES.error);
});
it('returns "warning" when there are no messages', () => {
const messages = [];
const result = getMessagesBlockType(messages);
expect(result).toBe(MESSAGE_ERROR_TYPES.warning);
});
});
});

View File

@@ -88,14 +88,16 @@ export async function createCourseXblock({
* @param {string} unitId - The ID of the course unit.
* @param {string} type - The action type (e.g., PUBLISH_TYPES.discardChanges).
* @param {boolean} isVisible - The visibility status for students.
* @param {boolean} groupAccess - Access group key set.
* @returns {Promise<any>} 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,
},
} : {}),
};

View File

@@ -16,7 +16,7 @@ const slice = createSlice({
},
unit: {},
courseSectionVertical: {},
courseVerticalChildren: [],
courseVerticalChildren: {},
},
reducers: {
fetchCourseItemSuccess: (state, { payload }) => {

View File

@@ -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 }));

View File

@@ -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) {

View File

@@ -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 ? (<p className="header-title__visibility-message mb-0">{message}</p>) : null;
};
useEffect(() => {
setTitleValue(unitTitle);
@@ -27,38 +49,46 @@ const HeaderTitle = ({
}, [unitTitle]);
return (
<div className="d-flex align-items-center lead" data-testid="unit-header-title">
{isTitleEditFormOpen ? (
<Form.Group className="m-0">
<Form.Control
ref={(e) => 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);
}
}}
/>
</Form.Group>
) : unitTitle}
<IconButton
alt={intl.formatMessage(messages.altButtonEdit)}
className="ml-1 flex-shrink-0"
iconAs={EditIcon}
onClick={handleTitleEdit}
/>
<IconButton
alt={intl.formatMessage(messages.altButtonSettings)}
className="flex-shrink-0"
iconAs={SettingsIcon}
onClick={() => {
}}
/>
</div>
<>
<div className="d-flex align-items-center lead" data-testid="unit-header-title">
{isTitleEditFormOpen ? (
<Form.Group className="m-0">
<Form.Control
ref={(e) => 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);
}
}}
/>
</Form.Group>
) : unitTitle}
<IconButton
alt={intl.formatMessage(messages.altButtonEdit)}
className="ml-1 flex-shrink-0"
iconAs={EditIcon}
onClick={handleTitleEdit}
/>
<IconButton
alt={intl.formatMessage(messages.altButtonSettings)}
className="flex-shrink-0"
iconAs={SettingsIcon}
onClick={openConfigureModal}
/>
<ConfigureModal
isOpen={isConfigureModalOpen}
onClose={closeConfigureModal}
onConfigureSubmit={onConfigureSubmit}
currentItemData={currentItemData}
/>
</div>
{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;

View File

@@ -0,0 +1,4 @@
.header-title__visibility-message {
font-size: $font-size-sm;
font-weight: $font-weight-normal;
}

View File

@@ -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(
<AppProvider store={store}>
@@ -22,6 +31,7 @@ const renderComponent = (props) => render(
isTitleEditFormOpen={isTitleEditFormOpen}
handleTitleEdit={handleTitleEdit}
handleTitleEditSubmit={handleTitleEditSubmit}
handleConfigureSubmit={handleConfigureSubmit}
{...props}
/>
</IntlProvider>
@@ -40,6 +50,11 @@ describe('<HeaderTitle />', () => {
});
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('<HeaderTitle />', () => {
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();
});
});

View File

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

View File

@@ -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 }) => {
@@ -59,6 +61,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));
@@ -121,6 +128,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
handleTitleEdit,
handleTitleEditSubmit,
handleCreateNewCourseXBlock,
handleConfigureSubmit,
courseVerticalChildren,
};
};

View File

@@ -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,

View File

@@ -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 = ({
</Tabs>
);
case COURSE_BLOCK_NAMES.vertical.id:
case COURSE_BLOCK_NAMES.component.id:
return (
<UnitTab
isXBlockComponent={COURSE_BLOCK_NAMES.component.id === category}
values={values}
setFieldValue={setFieldValue}
showWarning={visibilityState === VisibilityTypes.STAFF_ONLY && !ancestorHasStaffLock}
@@ -257,7 +263,7 @@ const ConfigureModal = ({
<div data-testid="configure-modal">
<ModalDialog.Header className="configure-modal__header">
<ModalDialog.Title>
{intl.formatMessage(messages.title, { title: displayName })}
{dialogTitle}
</ModalDialog.Title>
</ModalDialog.Header>
<Formik
@@ -268,7 +274,7 @@ const ConfigureModal = ({
validateOnChange
>
{({
values, handleSubmit, dirty, isValid, setFieldValue,
values, handleSubmit, setFieldValue,
}) => (
<>
<ModalDialog.Body className="configure-modal__body">
@@ -281,7 +287,10 @@ const ConfigureModal = ({
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.cancelButton)}
</ModalDialog.CloseButton>
<Button data-testid="configure-save-button" onClick={handleSubmit} disabled={!(dirty && isValid)}>
<Button
data-testid="configure-save-button"
onClick={handleSubmit}
>
{intl.formatMessage(messages.saveButton)}
</Button>
</ActionRow>
@@ -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;

View File

@@ -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}
/>
</IntlProvider>,
</AppProvider>,
@@ -129,12 +62,11 @@ describe('<ConfigureModal /> 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('<ConfigureModal /> 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(
<AppProvider store={store}>
<IntlProvider locale="en">
@@ -203,6 +92,7 @@ const renderSubsectionComponent = () => render(
isOpen
onClose={onCloseMock}
onConfigureSubmit={onConfigureSubmitMock}
currentItemData={currentSubsectionMock}
/>
</IntlProvider>,
</AppProvider>,
@@ -221,12 +111,11 @@ describe('<ConfigureModal /> 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('<ConfigureModal /> 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('<ConfigureModal /> 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(
<AppProvider store={store}>
<IntlProvider locale="en">
<ConfigureModal
isOpen
onClose={onCloseMock}
onConfigureSubmit={onConfigureSubmitMock}
currentItemData={currentUnitMock}
{...props}
/>
</IntlProvider>,
</AppProvider>,
@@ -359,14 +189,13 @@ describe('<ConfigureModal /> 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('<ConfigureModal /> 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('<ConfigureModal /> 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(
<AppProvider store={store}>
<IntlProvider locale="en">
<ConfigureModal
isOpen
isXBlockComponent
onClose={onCloseMock}
onConfigureSubmit={onConfigureSubmitMock}
currentItemData={currentXBlockMock}
{...props}
/>
</IntlProvider>,
</AppProvider>,
);
describe('<ConfigureModal /> 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);
const checkbox = getByTestId('unit-visibility-checkbox');
fireEvent.click(checkbox);
expect(saveButton).not.toBeDisabled();
expect(getByText(messages.unitSelectGroup.defaultMessage)).toBeInTheDocument();
currentUnitMock
.userPartitionInfo
.selectablePartitions[groupeTypeIndex].groups
.forEach(g => expect(getByText(g.name)).toBeInTheDocument());
});
expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument();
});
});

View File

@@ -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,

View File

@@ -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 (
<>
<h3 className="mt-3"><FormattedMessage {...messages.unitVisibility} /></h3>
<hr />
<Form.Checkbox checked={isVisibleToStaffOnly} onChange={handleChange} data-testid="unit-visibility-checkbox">
<FormattedMessage {...messages.hideFromLearners} />
</Form.Checkbox>
{showWarning && (
<Alert className="mt-2" variant="warning">
<FormattedMessage {...messages.unitVisibilityWarning} />
</Alert>
{!isXBlockComponent && (
<>
<h3 className="mt-3"><FormattedMessage {...messages.unitVisibility} /></h3>
<hr />
<Form.Checkbox checked={isVisibleToStaffOnly} onChange={handleChange} data-testid="unit-visibility-checkbox">
<FormattedMessage {...messages.hideFromLearners} />
</Form.Checkbox>
{showWarning && (
<Alert className="mt-2" variant="warning">
<FormattedMessage {...messages.unitVisibilityWarning} />
</Alert>
)}
<hr />
</>
)}
<hr />
<Form.Group controlId="groupSelect">
<Form.Label as="legend" className="font-weight-bold">
<FormattedMessage {...messages.restrictAccessTo} />
@@ -89,9 +103,19 @@ const UnitTab = ({
value={`${group.id}`}
name="selectedGroups"
/>
<Form.Label isInline>
{group.name}
</Form.Label>
<div>
<Form.Label
className={classNames({ 'text-danger': checkIsDeletedGroup(group) })}
isInline
>
{group.name}
</Form.Label>
{group.deleted && (
<Form.Control.Feedback type="invalid" hasIcon={false}>
{intl.formatMessage(messages.unitSelectDeletedGroupErrorMessage)}
</Form.Control.Feedback>
)}
</div>
</Form.Group>
))}
</div>
@@ -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,

View File

@@ -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,

View File

@@ -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: '',
},
};

View File

@@ -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',

View File

@@ -8,3 +8,4 @@
@import "./course-stepper/CouseStepper";
@import "./tag-count/TagCount";
@import "./modal-dropzone/ModalDropzone";
@import "./configure-modal/ConfigureModal";