From 6ec44b5f414d8d9344efd2e4828bb222762ceacf Mon Sep 17 00:00:00 2001 From: Ihor Romaniuk Date: Mon, 22 Apr 2024 17:13:16 +0200 Subject: [PATCH] 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 --- src/constants.js | 7 + src/course-outline/CourseOutline.jsx | 12 +- src/course-outline/CourseOutline.scss | 1 - src/course-outline/CourseOutline.test.jsx | 5 +- src/course-unit/CourseUnit.jsx | 8 +- src/course-unit/CourseUnit.scss | 1 + src/course-unit/CourseUnit.test.jsx | 79 ++++- .../__mocks__/courseVerticalChildren.js | 136 ++++++++- .../course-xblock/CourseXBlock.jsx | 58 +++- .../course-xblock/CourseXBlock.scss | 33 +- .../course-xblock/CourseXBlock.test.jsx | 141 ++++++++- src/course-unit/course-xblock/constants.js | 5 + src/course-unit/course-xblock/messages.js | 17 ++ .../xblock-messages/XBlockMessages.jsx | 49 +++ .../xblock-messages/XBlockMessages.test.jsx | 55 ++++ .../course-xblock/xblock-messages/utils.js | 16 + .../xblock-messages/utils.test.js | 44 +++ src/course-unit/data/api.js | 8 +- src/course-unit/data/slice.js | 2 +- src/course-unit/data/thunk.js | 12 +- src/course-unit/data/utils.js | 5 +- src/course-unit/header-title/HeaderTitle.jsx | 99 +++--- src/course-unit/header-title/HeaderTitle.scss | 4 + .../header-title/HeaderTitle.test.jsx | 47 +++ src/course-unit/header-title/messages.js | 13 + src/course-unit/hooks.jsx | 8 + .../configure-modal/AdvancedTab.jsx | 0 .../configure-modal/BasicTab.jsx | 4 +- .../configure-modal/ConfigureModal.jsx | 80 ++++- .../configure-modal/ConfigureModal.scss | 0 .../configure-modal/ConfigureModal.test.jsx | 287 +++++------------- .../configure-modal/PrereqSettings.jsx | 2 +- .../configure-modal/UnitTab.jsx | 58 +++- .../configure-modal/VisibilityTab.jsx | 2 +- .../configure-modal/__mocks__/index.js | 199 ++++++++++++ .../configure-modal/messages.js | 32 +- src/generic/styles.scss | 1 + 37 files changed, 1201 insertions(+), 329 deletions(-) create mode 100644 src/course-unit/course-xblock/constants.js create mode 100644 src/course-unit/course-xblock/xblock-messages/XBlockMessages.jsx create mode 100644 src/course-unit/course-xblock/xblock-messages/XBlockMessages.test.jsx create mode 100644 src/course-unit/course-xblock/xblock-messages/utils.js create mode 100644 src/course-unit/course-xblock/xblock-messages/utils.test.js create mode 100644 src/course-unit/header-title/HeaderTitle.scss rename src/{course-outline => generic}/configure-modal/AdvancedTab.jsx (100%) rename src/{course-outline => generic}/configure-modal/BasicTab.jsx (96%) rename src/{course-outline => generic}/configure-modal/ConfigureModal.jsx (77%) rename src/{course-outline => generic}/configure-modal/ConfigureModal.scss (100%) rename src/{course-outline => generic}/configure-modal/ConfigureModal.test.jsx (56%) rename src/{course-outline => generic}/configure-modal/PrereqSettings.jsx (98%) rename src/{course-outline => generic}/configure-modal/UnitTab.jsx (70%) rename src/{course-outline => generic}/configure-modal/VisibilityTab.jsx (98%) create mode 100644 src/generic/configure-modal/__mocks__/index.js rename src/{course-outline => generic}/configure-modal/messages.js (93%) diff --git a/src/constants.js b/src/constants.js index 2913884a94..47c441b8a2 100644 --- a/src/constants.js +++ b/src/constants.js @@ -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' }, +}); diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx index e046b78a54..fc1581687d 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.jsx @@ -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} /> { 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 }) => { )} {courseVerticalChildren.children.map(({ - name, blockId: id, blockType: type, shouldScroll, + name, blockId: id, blockType: type, shouldScroll, userPartitionInfo, validationMessages, }) => ( ))} diff --git a/src/course-unit/CourseUnit.scss b/src/course-unit/CourseUnit.scss index 270691ecae..6e380bf9d2 100644 --- a/src/course-unit/CourseUnit.scss +++ b/src/course-unit/CourseUnit.scss @@ -3,3 +3,4 @@ @import "./add-component/AddComponent"; @import "./course-xblock/CourseXBlock"; @import "./sidebar/Sidebar"; +@import "./header-title/HeaderTitle"; diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 78b616ad7a..24d55a9e1e 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -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('', () => { name: 'New Cloned XBlock', block_id: '1234567890', block_type: 'drag-and-drop-v2', + user_partition_info: {}, }, ], }); @@ -594,7 +596,7 @@ describe('', () => { }); }); - 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(); let courseUnitSidebar; let draftUnpublishedChangesHeading; @@ -617,7 +619,7 @@ describe('', () => { 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('', () => { 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('', () => { .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(); + 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(); + }); + }); }); diff --git a/src/course-unit/__mocks__/courseVerticalChildren.js b/src/course-unit/__mocks__/courseVerticalChildren.js index d7cc9bf611..a6d8102dc5 100644 --- a/src/course-unit/__mocks__/courseVerticalChildren.js +++ b/src/course-unit/__mocks__/courseVerticalChildren.js @@ -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, }; diff --git a/src/course-unit/course-xblock/CourseXBlock.jsx b/src/course-unit/course-xblock/CourseXBlock.jsx index 237a9c95ae..3f885692bc 100644 --- a/src/course-unit/course-xblock/CourseXBlock.jsx +++ b/src/course-unit/course-xblock/CourseXBlock.jsx @@ -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 = ({ {intl.formatMessage(messages.blockLabelButtonMove)} - + {intl.formatMessage(messages.blockLabelButtonManageAccess)} @@ -90,13 +111,21 @@ const CourseXBlock = ({ category="component" isOpen={isDeleteModalOpen} close={closeDeleteModal} - onDeleteSubmit={onXBlockDelete} + onDeleteSubmit={onDeleteSubmit} + /> + )} size="md" /> +
@@ -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; diff --git a/src/course-unit/course-xblock/CourseXBlock.scss b/src/course-unit/course-xblock/CourseXBlock.scss index 52c8e0bef5..262d19d653 100644 --- a/src/course-unit/course-xblock/CourseXBlock.scss +++ b/src/course-unit/course-xblock/CourseXBlock.scss @@ -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; } } diff --git a/src/course-unit/course-xblock/CourseXBlock.test.jsx b/src/course-unit/course-xblock/CourseXBlock.test.jsx index 6be85f150c..ad8e09184b 100644 --- a/src/course-unit/course-xblock/CourseXBlock.test.jsx +++ b/src/course-unit/course-xblock/CourseXBlock.test.jsx @@ -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 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 { COMPONENT_TYPES } from '../constants'; -import { courseVerticalChildrenMock } from '../__mocks__'; +import { PUBLISH_TYPES, COMPONENT_TYPES } from '../constants'; +import { courseSectionVerticalMock, courseVerticalChildrenMock } from '../__mocks__'; import CourseXBlock from './CourseXBlock'; - -import deleteModalMessages from '../../generic/delete-modal/messages'; 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('', () => { 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('', () => { 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('', () => { 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(); }); }); }); diff --git a/src/course-unit/course-xblock/constants.js b/src/course-unit/course-xblock/constants.js new file mode 100644 index 0000000000..5f0177ce72 --- /dev/null +++ b/src/course-unit/course-xblock/constants.js @@ -0,0 +1,5 @@ +// eslint-disable-next-line import/prefer-default-export +export const MESSAGE_ERROR_TYPES = { + error: 'error', + warning: 'warning', +}; diff --git a/src/course-unit/course-xblock/messages.js b/src/course-unit/course-xblock/messages.js index e4b6365424..1b78bfcc91 100644 --- a/src/course-unit/course-xblock/messages.js +++ b/src/course-unit/course-xblock/messages.js @@ -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', }, }); diff --git a/src/course-unit/course-xblock/xblock-messages/XBlockMessages.jsx b/src/course-unit/course-xblock/xblock-messages/XBlockMessages.jsx new file mode 100644 index 0000000000..0d7e32a4b1 --- /dev/null +++ b/src/course-unit/course-xblock/xblock-messages/XBlockMessages.jsx @@ -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 ( + + + {intl.formatMessage(messages.validationSummary)} + +
    + {validationMessages.map(({ text }) => ( +
  • {text}
  • + ))} +
+
+ ); +}; + +XBlockMessages.defaultProps = { + validationMessages: [], +}; + +XBlockMessages.propTypes = { + validationMessages: PropTypes.arrayOf(PropTypes.shape({ + type: PropTypes.string, + text: PropTypes.string, + })), +}; + +export default XBlockMessages; diff --git a/src/course-unit/course-xblock/xblock-messages/XBlockMessages.test.jsx b/src/course-unit/course-xblock/xblock-messages/XBlockMessages.test.jsx new file mode 100644 index 0000000000..8d7e36e98a --- /dev/null +++ b/src/course-unit/course-xblock/xblock-messages/XBlockMessages.test.jsx @@ -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( + + + , +); + +describe('', () => { + 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(); + }); +}); diff --git a/src/course-unit/course-xblock/xblock-messages/utils.js b/src/course-unit/course-xblock/xblock-messages/utils.js new file mode 100644 index 0000000000..2a815b7aa2 --- /dev/null +++ b/src/course-unit/course-xblock/xblock-messages/utils.js @@ -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; +}; diff --git a/src/course-unit/course-xblock/xblock-messages/utils.test.js b/src/course-unit/course-xblock/xblock-messages/utils.test.js new file mode 100644 index 0000000000..32e8dde4f6 --- /dev/null +++ b/src/course-unit/course-xblock/xblock-messages/utils.test.js @@ -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); + }); + }); +}); diff --git a/src/course-unit/data/api.js b/src/course-unit/data/api.js index 6520d1e1de..3ec12cef43 100644 --- a/src/course-unit/data/api.js +++ b/src/course-unit/data/api.js @@ -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} 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 d1d1edc7bc..a2b0726e71 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 }) => { @@ -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, }; }; 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)} - @@ -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/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";