diff --git a/package-lock.json b/package-lock.json index adb61ca687..062af01e6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@edx/frontend-component-footer": "^13.0.2", "@edx/frontend-component-header": "^5.1.0", "@edx/frontend-enterprise-hotjar": "^2.0.0", - "@edx/frontend-lib-content-components": "^2.1.5", + "@edx/frontend-lib-content-components": "^2.1.7", "@edx/frontend-platform": "7.0.1", "@edx/openedx-atlas": "^0.6.0", "@fortawesome/fontawesome-svg-core": "1.2.36", @@ -2589,9 +2589,9 @@ } }, "node_modules/@edx/frontend-lib-content-components": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-2.1.6.tgz", - "integrity": "sha512-Fn+zSnN7m7jxWcxrdfQqNBpeBEGl90VsZgcyRuuHknWAw5TXaRRohG+luyGxdE9c3N/rqZ43Ugv0YD3IisMOiw==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-2.1.7.tgz", + "integrity": "sha512-RjE263H/GabHmEe5EFaku7LSngkJitVbnWSxvRhsmO2o5LWwEctUcpkQaK7YCN6fpAlqXmcXVMrtM/lzP4j2Bw==", "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", diff --git a/package.json b/package.json index bf198982ac..75fa7904e0 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "@edx/frontend-component-footer": "^13.0.2", "@edx/frontend-component-header": "^5.1.0", "@edx/frontend-enterprise-hotjar": "^2.0.0", - "@edx/frontend-lib-content-components": "^2.1.5", + "@edx/frontend-lib-content-components": "^2.1.7", "@edx/frontend-platform": "7.0.1", "@edx/openedx-atlas": "^0.6.0", "@fortawesome/fontawesome-svg-core": "1.2.36", diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index 1f02383030..c914bcf5b1 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -22,6 +22,7 @@ import CourseExportPage from './export-page/CourseExportPage'; import CourseImportPage from './import-page/CourseImportPage'; import { DECODED_ROUTES } from './constants'; import CourseChecklist from './course-checklist'; +import GroupConfigurations from './group-configurations'; /** * As of this writing, these routes are mounted at a path prefixed with the following: @@ -100,6 +101,10 @@ const CourseAuthoringRoutes = () => { path="course_team" element={} /> + } + /> } 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/content-tags-drawer/ContentTagsDrawer.jsx b/src/content-tags-drawer/ContentTagsDrawer.jsx index a20e78bc5f..a629a16791 100644 --- a/src/content-tags-drawer/ContentTagsDrawer.jsx +++ b/src/content-tags-drawer/ContentTagsDrawer.jsx @@ -108,6 +108,36 @@ const ContentTagsDrawer = ({ id, onClose }) => { }, []); const taxonomies = useMemo(() => { + const sortTaxonomies = (taxonomiesList) => { + const taxonomiesWithData = taxonomiesList.filter( + (t) => t.contentTags.length !== 0, + ); + + // Count implicit tags per taxonomy. + // TODO This count is also calculated individually + // in ContentTagsCollapsible. It should only be calculated once. + const tagsCountBytaxonomy = {}; + taxonomiesWithData.forEach((tax) => { + tagsCountBytaxonomy[tax.id] = new Set( + tax.contentTags.flatMap(item => item.lineage), + ).size; + }); + + // Sort taxonomies with data by implicit count + const sortedTaxonomiesWithData = taxonomiesWithData.sort( + (a, b) => tagsCountBytaxonomy[b.id] - tagsCountBytaxonomy[a.id], + ); + + // Empty taxonomies sorted by name. + // Since the query returns sorted by name, + // it is not necessary to do another sorting here. + const emptyTaxonomies = taxonomiesList.filter( + (t) => t.contentTags.length === 0, + ); + + return [...sortedTaxonomiesWithData, ...emptyTaxonomies]; + }; + if (taxonomyListData && contentTaxonomyTagsData) { // Initialize list of content tags in taxonomies to populate const taxonomiesList = taxonomyListData.results.map((taxonomy) => ({ @@ -125,7 +155,7 @@ const ContentTagsDrawer = ({ id, onClose }) => { } }); - return taxonomiesList; + return sortTaxonomies(taxonomiesList); } return []; }, [taxonomyListData, contentTaxonomyTagsData]); diff --git a/src/content-tags-drawer/ContentTagsDrawer.test.jsx b/src/content-tags-drawer/ContentTagsDrawer.test.jsx index 91392689e5..a87f5f3e4d 100644 --- a/src/content-tags-drawer/ContentTagsDrawer.test.jsx +++ b/src/content-tags-drawer/ContentTagsDrawer.test.jsx @@ -96,16 +96,78 @@ describe('', () => { }, ], }, + { + name: 'Taxonomy 2', + taxonomyId: 124, + canTagObject: true, + tags: [ + { + value: 'Tag 1', + lineage: ['Tag 1'], + canDeleteObjecttag: true, + }, + ], + }, + { + name: 'Taxonomy 3', + taxonomyId: 125, + canTagObject: true, + tags: [ + { + value: 'Tag 1.1.1', + lineage: ['Tag 1', 'Tag 1.1', 'Tag 1.1.1'], + canDeleteObjecttag: true, + }, + ], + }, + { + name: '(B) Taxonomy 4', + taxonomyId: 126, + canTagObject: true, + tags: [], + }, + { + name: '(A) Taxonomy 5', + taxonomyId: 127, + canTagObject: true, + tags: [], + }, ], }, }); getTaxonomyListData.mockResolvedValue({ - results: [{ - id: 123, - name: 'Taxonomy 1', - description: 'This is a description 1', - canTagObject: true, - }], + results: [ + { + id: 123, + name: 'Taxonomy 1', + description: 'This is a description 1', + canTagObject: true, + }, + { + id: 124, + name: 'Taxonomy 2', + description: 'This is a description 2', + canTagObject: true, + }, + { + id: 125, + name: 'Taxonomy 3', + description: 'This is a description 3', + canTagObject: true, + }, + { + id: 127, + name: '(A) Taxonomy 5', + description: 'This is a description 5', + canTagObject: true, + }, + { + id: 126, + name: '(B) Taxonomy 4', + description: 'This is a description 4', + canTagObject: true, + }, + ], }); useTaxonomyTagsData.mockReturnValue({ @@ -388,4 +450,25 @@ describe('', () => { postMessageSpy.mockRestore(); }); + + it('should taxonomies must be ordered', async () => { + setupMockDataForStagedTagsTesting(); + render(); + expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument(); + + // First, taxonomies with content sorted by count implicit + // Later, empty taxonomies sorted by name + const expectedOrder = [ + 'Taxonomy 3', // 3 tags + 'Taxonomy 1', // 2 tags + 'Taxonomy 2', // 1 tag + '(A) Taxonomy 5', + '(B) Taxonomy 4', + ]; + + const taxonomies = screen.getAllByText(/.*Taxonomy.*/); + for (let i = 0; i !== taxonomies.length; i++) { + expect(taxonomies[i].textContent).toBe(expectedOrder[i]); + } + }); }); 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 b89b8463a1..bf63f61977 100644 --- a/src/course-unit/CourseUnit.scss +++ b/src/course-unit/CourseUnit.scss @@ -3,6 +3,7 @@ @import "./add-component/AddComponent"; @import "./course-xblock/CourseXBlock"; @import "./sidebar/Sidebar"; +@import "./header-title/HeaderTitle"; div.xblock-highlight { animation: 5s glow; @@ -17,4 +18,4 @@ div.xblock-highlight { 100% { box-shadow: unset; } -} +} \ No newline at end of file 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 03a4760b08..a41c66af8a 100644 --- a/src/course-unit/course-xblock/CourseXBlock.jsx +++ b/src/course-unit/course-xblock/CourseXBlock.jsx @@ -9,16 +9,21 @@ import { useSelector } from 'react-redux'; import { useNavigate, useSearchParams } 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(); @@ -27,7 +32,18 @@ const CourseXBlock = ({ const locatorId = searchParams.get('show'); const isScrolledToElement = locatorId === id; - 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(); }; @@ -43,6 +59,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 || isScrolledToElement)) { @@ -59,6 +79,7 @@ const CourseXBlock = ({ {intl.formatMessage(messages.blockLabelButtonMove)} - + {intl.formatMessage(messages.blockLabelButtonManageAccess)} @@ -98,13 +119,21 @@ const CourseXBlock = ({ category="component" isOpen={isDeleteModalOpen} close={closeDeleteModal} - onDeleteSubmit={onXBlockDelete} + onDeleteSubmit={onDeleteSubmit} + /> + )} size="md" /> +
@@ -113,6 +142,7 @@ const CourseXBlock = ({ }; CourseXBlock.defaultProps = { + validationMessages: [], shouldScroll: false, }; @@ -121,10 +151,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 3d636df772..7989f46f9d 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -11,6 +11,7 @@ import { fetchCourseVerticalChildrenData, deleteUnitItemQuery, duplicateUnitItemQuery, + editCourseUnitVisibilityAndData, } from './data/thunk'; import { getCourseSectionVertical, @@ -21,6 +22,7 @@ import { getSequenceStatus, } from './data/selectors'; import { changeEditTitleFormOpen, updateQueryPendingStatus } from './data/slice'; +import { PUBLISH_TYPES } from './constants'; // eslint-disable-next-line import/prefer-default-export export const useCourseUnit = ({ courseId, blockId }) => { @@ -60,6 +62,11 @@ export const useCourseUnit = ({ courseId, blockId }) => { dispatch(changeEditTitleFormOpen(!isTitleEditFormOpen)); }; + const handleConfigureSubmit = (id, isVisible, groupAccess, closeModalFn) => { + dispatch(editCourseUnitVisibilityAndData(id, PUBLISH_TYPES.republish, isVisible, groupAccess, true, blockId)); + closeModalFn(); + }; + const handleTitleEditSubmit = (displayName) => { if (unitTitle !== displayName) { dispatch(editCourseItemQuery(blockId, displayName, sequenceId)); @@ -131,6 +138,7 @@ export const useCourseUnit = ({ courseId, blockId }) => { handleTitleEdit, handleTitleEditSubmit, handleCreateNewCourseXBlock, + handleConfigureSubmit, courseVerticalChildren, }; }; diff --git a/src/files-and-videos/generic/FileInput.jsx b/src/files-and-videos/generic/FileInput.jsx index 455853722f..c953994d22 100644 --- a/src/files-and-videos/generic/FileInput.jsx +++ b/src/files-and-videos/generic/FileInput.jsx @@ -11,7 +11,7 @@ export const useFileInput = ({ const click = () => ref.current.click(); const addFile = (e) => { const { files } = e.target; - setSelectedRows(files); + setSelectedRows([...files]); onAddFile(Object.values(files)); setAddOpen(); e.target.value = ''; diff --git a/src/files-and-videos/generic/messages.js b/src/files-and-videos/generic/messages.js index d6f6d03e87..1906f1ba78 100644 --- a/src/files-and-videos/generic/messages.js +++ b/src/files-and-videos/generic/messages.js @@ -8,7 +8,7 @@ const messages = defineMessages({ }, apiStatusToastMessage: { id: 'course-authoring.files-and-upload.apiStatus.message', - defaultMessage: '{actionType} {selectedRowCount} {fileType}(s)', + defaultMessage: '{actionType} {selectedRowCount} {selectedRowCount, plural, one {{fileType}} other {{fileType}s}}', description: 'This message is showed in the toast when action is applied to files', }, apiStatusAddingAction: { diff --git a/src/course-outline/configure-modal/AdvancedTab.jsx b/src/generic/configure-modal/AdvancedTab.jsx similarity index 100% rename from src/course-outline/configure-modal/AdvancedTab.jsx rename to src/generic/configure-modal/AdvancedTab.jsx diff --git a/src/course-outline/configure-modal/BasicTab.jsx b/src/generic/configure-modal/BasicTab.jsx similarity index 96% rename from src/course-outline/configure-modal/BasicTab.jsx rename to src/generic/configure-modal/BasicTab.jsx index 173bc34939..182de34df1 100644 --- a/src/course-outline/configure-modal/BasicTab.jsx +++ b/src/generic/configure-modal/BasicTab.jsx @@ -1,9 +1,9 @@ -import React from 'react'; import PropTypes from 'prop-types'; import { Stack, Form } from '@openedx/paragon'; import { FormattedMessage, injectIntl, useIntl } from '@edx/frontend-platform/i18n'; + +import { DatepickerControl, DATEPICKER_TYPES } from '../datepicker-control'; import messages from './messages'; -import { DatepickerControl, DATEPICKER_TYPES } from '../../generic/datepicker-control'; const BasicTab = ({ values, diff --git a/src/course-outline/configure-modal/ConfigureModal.jsx b/src/generic/configure-modal/ConfigureModal.jsx similarity index 77% rename from src/course-outline/configure-modal/ConfigureModal.jsx rename to src/generic/configure-modal/ConfigureModal.jsx index 3e7556b466..a78bd386a7 100644 --- a/src/course-outline/configure-modal/ConfigureModal.jsx +++ b/src/generic/configure-modal/ConfigureModal.jsx @@ -11,12 +11,10 @@ import { Tab, Tabs, } from '@openedx/paragon'; -import { useSelector } from 'react-redux'; import { Formik } from 'formik'; import { VisibilityTypes } from '../../data/constants'; -import { COURSE_BLOCK_NAMES } from '../constants'; -import { getCurrentItem, getProctoredExamsFlag } from '../data/selectors'; +import { COURSE_BLOCK_NAMES } from '../../constants'; import messages from './messages'; import BasicTab from './BasicTab'; import VisibilityTab from './VisibilityTab'; @@ -27,6 +25,9 @@ const ConfigureModal = ({ isOpen, onClose, onConfigureSubmit, + currentItemData, + enableProctoredExams, + isXBlockComponent, }) => { const intl = useIntl(); const { @@ -57,8 +58,7 @@ const ConfigureModal = ({ supportsOnboarding, showReviewRules, onlineProctoringRules, - } = useSelector(getCurrentItem); - const enableProctoredExams = useSelector(getProctoredExamsFlag); + } = currentItemData; const getSelectedGroups = () => { if (userPartitionInfo?.selectedPartitionIndex >= 0) { @@ -81,7 +81,6 @@ const ConfigureModal = ({ const initialValues = { releaseDate: sectionStartDate, isVisibleToStaffOnly: visibilityState === VisibilityTypes.STAFF_ONLY, - saveButtonDisabled: true, graderType: format == null ? 'notgraded' : format, dueDate: due == null ? '' : due, isTimeLimited, @@ -132,6 +131,10 @@ const ConfigureModal = ({ const isSubsection = category === COURSE_BLOCK_NAMES.sequential.id; + const dialogTitle = isXBlockComponent + ? intl.formatMessage(messages.componentTitle, { title: displayName }) + : intl.formatMessage(messages.title, { title: displayName }); + const handleSave = (data) => { const groupAccess = {}; switch (category) { @@ -159,6 +162,7 @@ const ConfigureModal = ({ ); break; case COURSE_BLOCK_NAMES.vertical.id: + case COURSE_BLOCK_NAMES.component.id: // groupAccess should be {partitionId: [group1, group2]} or {} if selectedPartitionIndex === -1 if (data.selectedPartitionIndex >= 0) { const partitionId = userPartitionInfo.selectablePartitions[data.selectedPartitionIndex].id; @@ -232,8 +236,10 @@ const ConfigureModal = ({ ); case COURSE_BLOCK_NAMES.vertical.id: + case COURSE_BLOCK_NAMES.component.id: return ( - {intl.formatMessage(messages.title, { title: displayName })} + {dialogTitle} {({ - values, handleSubmit, dirty, isValid, setFieldValue, + values, handleSubmit, setFieldValue, }) => ( <> @@ -281,7 +287,10 @@ const ConfigureModal = ({ {intl.formatMessage(messages.cancelButton)} - @@ -294,10 +303,63 @@ const ConfigureModal = ({ ); }; +ConfigureModal.defaultProps = { + isXBlockComponent: false, + enableProctoredExams: false, +}; + ConfigureModal.propTypes = { isOpen: PropTypes.bool.isRequired, onClose: PropTypes.func.isRequired, onConfigureSubmit: PropTypes.func.isRequired, + enableProctoredExams: PropTypes.bool, + currentItemData: PropTypes.shape({ + displayName: PropTypes.string, + start: PropTypes.string, + visibilityState: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + due: PropTypes.string, + isTimeLimited: PropTypes.bool, + defaultTimeLimitMinutes: PropTypes.number, + hideAfterDue: PropTypes.bool, + showCorrectness: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + courseGraders: PropTypes.arrayOf(PropTypes.string), + category: PropTypes.string, + format: PropTypes.string, + userPartitionInfo: PropTypes.shape({ + selectablePartitions: PropTypes.arrayOf(PropTypes.shape({ + groups: PropTypes.arrayOf(PropTypes.shape({ + deleted: PropTypes.bool, + id: PropTypes.number, + name: PropTypes.string, + selected: PropTypes.bool, + })), + id: PropTypes.number, + name: PropTypes.string, + scheme: PropTypes.string, + })), + selectedPartitionIndex: PropTypes.number, + selectedGroupsLabel: PropTypes.string, + }), + ancestorHasStaffLock: PropTypes.bool, + isPrereq: PropTypes.bool, + prereqs: PropTypes.arrayOf({ + blockDisplayName: PropTypes.string, + blockUsageKey: PropTypes.string, + }), + prereq: PropTypes.number, + prereqMinScore: PropTypes.number, + prereqMinCompletion: PropTypes.number, + releasedToStudents: PropTypes.bool, + wasExamEverLinkedWithExternal: PropTypes.bool, + isProctoredExam: PropTypes.bool, + isOnboardingExam: PropTypes.bool, + isPracticeExam: PropTypes.bool, + examReviewRules: PropTypes.string, + supportsOnboarding: PropTypes.bool, + showReviewRules: PropTypes.bool, + onlineProctoringRules: PropTypes.string, + }).isRequired, + isXBlockComponent: PropTypes.bool, }; export default ConfigureModal; diff --git a/src/course-outline/configure-modal/ConfigureModal.scss b/src/generic/configure-modal/ConfigureModal.scss similarity index 100% rename from src/course-outline/configure-modal/ConfigureModal.scss rename to src/generic/configure-modal/ConfigureModal.scss diff --git a/src/course-outline/configure-modal/ConfigureModal.test.jsx b/src/generic/configure-modal/ConfigureModal.test.jsx similarity index 56% rename from src/course-outline/configure-modal/ConfigureModal.test.jsx rename to src/generic/configure-modal/ConfigureModal.test.jsx index 9756f32467..3c4d699446 100644 --- a/src/course-outline/configure-modal/ConfigureModal.test.jsx +++ b/src/generic/configure-modal/ConfigureModal.test.jsx @@ -1,7 +1,6 @@ -import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { useSelector } from 'react-redux'; import { initializeMockApp } from '@edx/frontend-platform'; import MockAdapter from 'axios-mock-adapter'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; @@ -9,6 +8,12 @@ import { AppProvider } from '@edx/frontend-platform/react'; import initializeStore from '../../store'; import ConfigureModal from './ConfigureModal'; +import { + currentSectionMock, + currentSubsectionMock, + currentUnitMock, + currentXBlockMock, +} from './__mocks__'; import messages from './messages'; // eslint-disable-next-line no-unused-vars @@ -28,79 +33,6 @@ jest.mock('react-router-dom', () => ({ }), })); -const currentSectionMock = { - displayName: 'Section1', - category: 'chapter', - start: '2025-08-10T10:00:00Z', - visibilityState: true, - format: 'Not Graded', - childInfo: { - displayName: 'Subsection', - children: [ - { - displayName: 'Subsection 1', - id: 1, - category: 'sequential', - due: '', - start: '2025-08-10T10:00:00Z', - visibilityState: true, - defaultTimeLimitMinutes: null, - hideAfterDue: false, - showCorrectness: false, - format: 'Homework', - courseGraders: ['Homework', 'Exam'], - childInfo: { - displayName: 'Unit', - children: [ - { - id: 11, - displayName: 'Subsection_1 Unit 1', - }, - ], - }, - }, - { - displayName: 'Subsection 2', - id: 2, - category: 'sequential', - due: '', - start: '2025-08-10T10:00:00Z', - visibilityState: true, - defaultTimeLimitMinutes: null, - hideAfterDue: false, - showCorrectness: false, - format: 'Homework', - courseGraders: ['Homework', 'Exam'], - childInfo: { - displayName: 'Unit', - children: [ - { - id: 21, - displayName: 'Subsection_2 Unit 1', - }, - ], - }, - }, - { - displayName: 'Subsection 3', - id: 3, - category: 'sequential', - due: '', - start: '2025-08-10T10:00:00Z', - visibilityState: true, - defaultTimeLimitMinutes: null, - hideAfterDue: false, - showCorrectness: false, - format: 'Homework', - courseGraders: ['Homework', 'Exam'], - childInfo: { - children: [], - }, - }, - ], - }, -}; - const onCloseMock = jest.fn(); const onConfigureSubmitMock = jest.fn(); @@ -111,6 +43,7 @@ const renderComponent = () => render( isOpen onClose={onCloseMock} onConfigureSubmit={onConfigureSubmitMock} + currentItemData={currentSectionMock} /> , , @@ -129,12 +62,11 @@ describe(' for Section', () => { store = initializeStore(); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - useSelector.mockReturnValue(currentSectionMock); }); it('renders ConfigureModal component correctly', () => { const { getByText, getByRole } = renderComponent(); - expect(getByText(`${currentSectionMock.displayName} Settings`)).toBeInTheDocument(); + expect(getByText(`${currentSectionMock.displayName} settings`)).toBeInTheDocument(); expect(getByText(messages.basicTabTitle.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.visibilityTabTitle.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.releaseDate.defaultMessage)).toBeInTheDocument(); @@ -147,55 +79,12 @@ describe(' for Section', () => { const { getByRole, getByText } = renderComponent(); const visibilityTab = getByRole('tab', { name: messages.visibilityTabTitle.defaultMessage }); - fireEvent.click(visibilityTab); - expect(getByText('Section Visibility')).toBeInTheDocument(); + userEvent.click(visibilityTab); + expect(getByText('Section visibility')).toBeInTheDocument(); expect(getByText(messages.hideFromLearners.defaultMessage)).toBeInTheDocument(); }); - - it('disables the Save button and enables it if there is a change', () => { - const { getByRole, getByPlaceholderText, getByTestId } = renderComponent(); - - const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage }); - expect(saveButton).toBeDisabled(); - - const input = getByPlaceholderText('MM/DD/YYYY'); - fireEvent.change(input, { target: { value: '12/15/2023' } }); - - const visibilityTab = getByRole('tab', { name: messages.visibilityTabTitle.defaultMessage }); - fireEvent.click(visibilityTab); - const checkbox = getByTestId('visibility-checkbox'); - fireEvent.click(checkbox); - expect(saveButton).not.toBeDisabled(); - }); }); -const currentSubsectionMock = { - displayName: 'Subsection 1', - id: 1, - category: 'sequential', - due: '', - start: '2025-08-10T10:00:00Z', - visibilityState: true, - defaultTimeLimitMinutes: null, - hideAfterDue: false, - showCorrectness: false, - format: 'Homework', - courseGraders: ['Homework', 'Exam'], - childInfo: { - displayName: 'Unit', - children: [ - { - id: 11, - displayName: 'Subsection_1 Unit 1', - }, - { - id: 12, - displayName: 'Subsection_1 Unit 2', - }, - ], - }, -}; - const renderSubsectionComponent = () => render( @@ -203,6 +92,7 @@ const renderSubsectionComponent = () => render( isOpen onClose={onCloseMock} onConfigureSubmit={onConfigureSubmitMock} + currentItemData={currentSubsectionMock} /> , , @@ -221,12 +111,11 @@ describe(' for Subsection', () => { store = initializeStore(); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - useSelector.mockReturnValue(currentSubsectionMock); }); it('renders subsection ConfigureModal component correctly', () => { const { getByText, getByRole } = renderSubsectionComponent(); - expect(getByText(`${currentSubsectionMock.displayName} Settings`)).toBeInTheDocument(); + expect(getByText(`${currentSubsectionMock.displayName} settings`)).toBeInTheDocument(); expect(getByText(messages.basicTabTitle.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.visibilityTabTitle.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.advancedTabTitle.defaultMessage)).toBeInTheDocument(); @@ -244,8 +133,8 @@ describe(' for Subsection', () => { const { getByRole, getByText } = renderSubsectionComponent(); const visibilityTab = getByRole('tab', { name: messages.visibilityTabTitle.defaultMessage }); - fireEvent.click(visibilityTab); - expect(getByText('Subsection Visibility')).toBeInTheDocument(); + userEvent.click(visibilityTab); + expect(getByText('Subsection visibility')).toBeInTheDocument(); expect(getByText(messages.showEntireSubsection.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.showEntireSubsectionDescription.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.hideContentAfterDue.defaultMessage)).toBeInTheDocument(); @@ -265,82 +154,23 @@ describe(' for Subsection', () => { const { getByRole, getByText } = renderSubsectionComponent(); const advancedTab = getByRole('tab', { name: messages.advancedTabTitle.defaultMessage }); - fireEvent.click(advancedTab); + userEvent.click(advancedTab); expect(getByText(messages.setSpecialExam.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.none.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.timed.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.timedDescription.defaultMessage)).toBeInTheDocument(); }); - - it('disables the Save button and enables it if there is a change', () => { - const { getByRole, getByTestId } = renderSubsectionComponent(); - - const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage }); - expect(saveButton).toBeDisabled(); - - const input = getByTestId('grader-type-select'); - fireEvent.change(input, { target: { value: 'Exam' } }); - expect(saveButton).not.toBeDisabled(); - }); }); -const currentUnitMock = { - displayName: 'Unit 1', - id: 1, - category: 'vertical', - due: '', - start: '2025-08-10T10:00:00Z', - visibilityState: true, - defaultTimeLimitMinutes: null, - hideAfterDue: false, - showCorrectness: false, - userPartitionInfo: { - selectablePartitions: [ - { - id: 50, - name: 'Enrollment Track Groups', - scheme: 'enrollment_track', - groups: [ - { - id: 6, - name: 'Honor', - selected: false, - deleted: false, - }, - { - id: 2, - name: 'Verified', - selected: false, - deleted: false, - }, - ], - }, - { - id: 1508065533, - name: 'Content Groups', - scheme: 'cohort', - groups: [ - { - id: 1224170703, - name: 'Content Group 1', - selected: false, - deleted: false, - }, - ], - }, - ], - selectedPartitionIndex: -1, - selectedGroupsLabel: '', - }, -}; - -const renderUnitComponent = () => render( +const renderUnitComponent = (props) => render( , , @@ -359,14 +189,13 @@ describe(' for Unit', () => { store = initializeStore(); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - useSelector.mockReturnValue(currentUnitMock); }); it('renders unit ConfigureModal component correctly', () => { const { getByText, queryByText, getByRole, getByTestId, } = renderUnitComponent(); - expect(getByText(`${currentUnitMock.displayName} Settings`)).toBeInTheDocument(); + expect(getByText(`${currentUnitMock.displayName} settings`)).toBeInTheDocument(); expect(getByText(messages.unitVisibility.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.hideFromLearners.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.restrictAccessTo.defaultMessage)).toBeInTheDocument(); @@ -375,8 +204,8 @@ describe(' for Unit', () => { expect(queryByText(messages.unitSelectGroup.defaultMessage)).not.toBeInTheDocument(); const input = getByTestId('group-type-select'); - [0, 1].forEach(groupeTypeIndex => { - fireEvent.change(input, { target: { value: groupeTypeIndex } }); + ['0', '1'].forEach(groupeTypeIndex => { + userEvent.selectOptions(input, groupeTypeIndex); expect(getByText(messages.unitSelectGroup.defaultMessage)).toBeInTheDocument(); currentUnitMock @@ -388,32 +217,62 @@ describe(' for Unit', () => { expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument(); expect(getByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument(); }); +}); - it('disables the Save button and enables it if there is a change', () => { - useSelector.mockReturnValue( - { - ...currentUnitMock, - userPartitionInfo: { - ...currentUnitMock.userPartitionInfo, - selectedPartitionIndex: 0, - }, +const renderXBlockComponent = (props) => render( + + + + , + , +); + +describe(' for XBlock', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], }, - ); - const { getByRole, getByTestId } = renderUnitComponent(); + }); - const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage }); - expect(saveButton).toBeDisabled(); + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + it('renders unit ConfigureModal component correctly', () => { + const { + getByText, queryByText, getByRole, getByTestId, + } = renderXBlockComponent(); + expect(getByText(`Editing access for: ${currentUnitMock.displayName}`)).toBeInTheDocument(); + expect(queryByText(messages.unitVisibility.defaultMessage)).not.toBeInTheDocument(); + expect(queryByText(messages.hideFromLearners.defaultMessage)).not.toBeInTheDocument(); + expect(getByText(messages.restrictAccessTo.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.unitSelectGroupType.defaultMessage)).toBeInTheDocument(); + + expect(queryByText(messages.unitSelectGroup.defaultMessage)).not.toBeInTheDocument(); const input = getByTestId('group-type-select'); - // unrestrict access - fireEvent.change(input, { target: { value: -1 } }); - expect(saveButton).not.toBeDisabled(); - fireEvent.change(input, { target: { value: 0 } }); - expect(saveButton).toBeDisabled(); + ['0', '1'].forEach(groupeTypeIndex => { + userEvent.selectOptions(input, groupeTypeIndex); + + expect(getByText(messages.unitSelectGroup.defaultMessage)).toBeInTheDocument(); + currentUnitMock + .userPartitionInfo + .selectablePartitions[groupeTypeIndex].groups + .forEach(g => expect(getByText(g.name)).toBeInTheDocument()); + }); - const checkbox = getByTestId('unit-visibility-checkbox'); - fireEvent.click(checkbox); - expect(saveButton).not.toBeDisabled(); + expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument(); }); }); diff --git a/src/course-outline/configure-modal/PrereqSettings.jsx b/src/generic/configure-modal/PrereqSettings.jsx similarity index 98% rename from src/course-outline/configure-modal/PrereqSettings.jsx rename to src/generic/configure-modal/PrereqSettings.jsx index b79ffbf34d..74c5f7148e 100644 --- a/src/course-outline/configure-modal/PrereqSettings.jsx +++ b/src/generic/configure-modal/PrereqSettings.jsx @@ -4,7 +4,7 @@ import { Form } from '@openedx/paragon'; import { FormattedMessage, injectIntl, useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; -import FormikControl from '../../generic/FormikControl'; +import FormikControl from '../FormikControl'; const PrereqSettings = ({ values, diff --git a/src/course-outline/configure-modal/UnitTab.jsx b/src/generic/configure-modal/UnitTab.jsx similarity index 70% rename from src/course-outline/configure-modal/UnitTab.jsx rename to src/generic/configure-modal/UnitTab.jsx index ec838711da..2c38ab17d0 100644 --- a/src/course-outline/configure-modal/UnitTab.jsx +++ b/src/generic/configure-modal/UnitTab.jsx @@ -5,10 +5,12 @@ import { FormattedMessage, injectIntl, useIntl, } from '@edx/frontend-platform/i18n'; import { Field } from 'formik'; +import classNames from 'classnames'; import messages from './messages'; const UnitTab = ({ + isXBlockComponent, values, setFieldValue, showWarning, @@ -18,6 +20,7 @@ const UnitTab = ({ const { isVisibleToStaffOnly, selectedPartitionIndex, + selectedGroups, } = values; const handleChange = (e) => { @@ -26,21 +29,32 @@ const UnitTab = ({ const handleSelect = (e) => { setFieldValue('selectedPartitionIndex', parseInt(e.target.value, 10)); + setFieldValue('selectedGroups', []); + }; + + const checkIsDeletedGroup = (group) => { + const isGroupSelected = selectedGroups.includes(group.id.toString()); + + return group.deleted && isGroupSelected; }; return ( <> -

-
- - - - {showWarning && ( - - - + {!isXBlockComponent && ( + <> +

+
+ + + + {showWarning && ( + + + + )} +
+ )} -
@@ -89,9 +103,19 @@ const UnitTab = ({ value={`${group.id}`} name="selectedGroups" /> - - {group.name} - +
+ + {group.name} + + {group.deleted && ( + + {intl.formatMessage(messages.unitSelectDeletedGroupErrorMessage)} + + )} +
))}
@@ -103,13 +127,21 @@ const UnitTab = ({ ); }; +UnitTab.defaultProps = { + isXBlockComponent: false, +}; + UnitTab.propTypes = { + isXBlockComponent: PropTypes.bool, values: PropTypes.shape({ isVisibleToStaffOnly: PropTypes.bool.isRequired, selectedPartitionIndex: PropTypes.oneOfType([ PropTypes.string, PropTypes.number, ]).isRequired, + selectedGroups: PropTypes.oneOfType([ + PropTypes.string, + ]), }).isRequired, setFieldValue: PropTypes.func.isRequired, showWarning: PropTypes.bool.isRequired, diff --git a/src/course-outline/configure-modal/VisibilityTab.jsx b/src/generic/configure-modal/VisibilityTab.jsx similarity index 98% rename from src/course-outline/configure-modal/VisibilityTab.jsx rename to src/generic/configure-modal/VisibilityTab.jsx index 44ee964619..c6ce99da4d 100644 --- a/src/course-outline/configure-modal/VisibilityTab.jsx +++ b/src/generic/configure-modal/VisibilityTab.jsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import { Alert, Form } from '@openedx/paragon'; import { FormattedMessage, injectIntl, useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; -import { COURSE_BLOCK_NAMES } from '../constants'; +import { COURSE_BLOCK_NAMES } from '../../constants'; const VisibilityTab = ({ values, diff --git a/src/generic/configure-modal/__mocks__/index.js b/src/generic/configure-modal/__mocks__/index.js new file mode 100644 index 0000000000..8e69d242d9 --- /dev/null +++ b/src/generic/configure-modal/__mocks__/index.js @@ -0,0 +1,199 @@ +export const currentSectionMock = { + displayName: 'Section1', + category: 'chapter', + start: '2025-08-10T10:00:00Z', + visibilityState: true, + format: 'Not Graded', + childInfo: { + displayName: 'Subsection', + children: [ + { + displayName: 'Subsection 1', + id: 1, + category: 'sequential', + due: '', + start: '2025-08-10T10:00:00Z', + visibilityState: true, + defaultTimeLimitMinutes: null, + hideAfterDue: false, + showCorrectness: false, + format: 'Homework', + courseGraders: ['Homework', 'Exam'], + childInfo: { + displayName: 'Unit', + children: [ + { + id: 11, + displayName: 'Subsection_1 Unit 1', + }, + ], + }, + }, + { + displayName: 'Subsection 2', + id: 2, + category: 'sequential', + due: '', + start: '2025-08-10T10:00:00Z', + visibilityState: true, + defaultTimeLimitMinutes: null, + hideAfterDue: false, + showCorrectness: false, + format: 'Homework', + courseGraders: ['Homework', 'Exam'], + childInfo: { + displayName: 'Unit', + children: [ + { + id: 21, + displayName: 'Subsection_2 Unit 1', + }, + ], + }, + }, + { + displayName: 'Subsection 3', + id: 3, + category: 'sequential', + due: '', + start: '2025-08-10T10:00:00Z', + visibilityState: true, + defaultTimeLimitMinutes: null, + hideAfterDue: false, + showCorrectness: false, + format: 'Homework', + courseGraders: ['Homework', 'Exam'], + childInfo: { + children: [], + }, + }, + ], + }, +}; + +export const currentSubsectionMock = { + displayName: 'Subsection 1', + id: 1, + category: 'sequential', + due: '', + start: '2025-08-10T10:00:00Z', + visibilityState: true, + defaultTimeLimitMinutes: null, + hideAfterDue: false, + showCorrectness: false, + format: 'Homework', + courseGraders: ['Homework', 'Exam'], + childInfo: { + displayName: 'Unit', + children: [ + { + id: 11, + displayName: 'Subsection_1 Unit 1', + }, + { + id: 12, + displayName: 'Subsection_1 Unit 2', + }, + ], + }, +}; + +export const currentUnitMock = { + displayName: 'Unit 1', + id: 1, + category: 'vertical', + due: '', + start: '2025-08-10T10:00:00Z', + visibilityState: true, + defaultTimeLimitMinutes: null, + hideAfterDue: false, + showCorrectness: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 6, + name: 'Honor', + selected: false, + deleted: false, + }, + { + id: 2, + name: 'Verified', + selected: false, + deleted: false, + }, + ], + }, + { + id: 1508065533, + name: 'Content Groups', + scheme: 'cohort', + groups: [ + { + id: 1224170703, + name: 'Content Group 1', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, +}; + +export const currentXBlockMock = { + displayName: 'Unit 1', + id: 1, + category: 'component', + due: '', + start: '2025-08-10T10:00:00Z', + visibilityState: true, + defaultTimeLimitMinutes: null, + hideAfterDue: false, + showCorrectness: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 6, + name: 'Honor', + selected: false, + deleted: false, + }, + { + id: 2, + name: 'Verified', + selected: false, + deleted: false, + }, + ], + }, + { + id: 1508065533, + name: 'Content Groups', + scheme: 'cohort', + groups: [ + { + id: 1224170703, + name: 'Content Group 1', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, +}; diff --git a/src/course-outline/configure-modal/messages.js b/src/generic/configure-modal/messages.js similarity index 93% rename from src/course-outline/configure-modal/messages.js rename to src/generic/configure-modal/messages.js index 316cbc0fb0..d27f943dc6 100644 --- a/src/course-outline/configure-modal/messages.js +++ b/src/generic/configure-modal/messages.js @@ -3,7 +3,12 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ title: { id: 'course-authoring.course-outline.configure-modal.title', - defaultMessage: '{title} Settings', + defaultMessage: '{title} settings', + }, + componentTitle: { + id: 'course-authoring.course-outline.configure-modal.component.title', + defaultMessage: 'Editing access for: {title}', + description: 'The visibility modal title for unit', }, basicTabTitle: { id: 'course-authoring.course-outline.configure-modal.basic-tab.title', @@ -15,15 +20,15 @@ const messages = defineMessages({ }, releaseDateAndTime: { id: 'course-authoring.course-outline.configure-modal.basic-tab.release-date-and-time', - defaultMessage: 'Release Date and Time', + defaultMessage: 'Release date and time', }, releaseDate: { id: 'course-authoring.course-outline.configure-modal.basic-tab.release-date', - defaultMessage: 'Release Date:', + defaultMessage: 'Release date:', }, releaseTimeUTC: { id: 'course-authoring.course-outline.configure-modal.basic-tab.release-time-UTC', - defaultMessage: 'Release Time in UTC:', + defaultMessage: 'Release time in UTC:', }, visibilityTabTitle: { id: 'course-authoring.course-outline.configure-modal.visibility-tab.title', @@ -31,11 +36,11 @@ const messages = defineMessages({ }, visibilitySectionTitle: { id: 'course-authoring.course-outline.configure-modal.visibility-tab.section-visibility', - defaultMessage: '{visibilityTitle} Visibility', + defaultMessage: '{visibilityTitle} visibility', }, unitVisibility: { id: 'course-authoring.course-outline.configure-modal.visibility-tab.unit-visibility', - defaultMessage: 'Unit Visibility', + defaultMessage: 'Unit visibility', }, hideFromLearners: { id: 'course-authoring.course-outline.configure-modal.visibility.hide-from-learners', @@ -65,6 +70,11 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.configure-modal.unit-tab.unit-select-group-type', defaultMessage: 'Select a group type', }, + unitSelectDeletedGroupErrorMessage: { + id: 'course-authoring.course-outline.configure-modal.unit-tab.unit-select-group-deleted-error-message', + defaultMessage: 'This group no longer exists. Choose another group or remove the access restriction.', + description: 'The alert text of no longer available group', + }, unitAllLearnersAndStaff: { id: 'course-authoring.course-outline.configure-modal.unit-tab.unit-all-learners-staff', defaultMessage: 'All Learners and Staff', @@ -87,15 +97,15 @@ const messages = defineMessages({ }, dueDate: { id: 'course-authoring.course-outline.configure-modal.basic-tab.due-date', - defaultMessage: 'Due Date:', + defaultMessage: 'Due date:', }, dueTimeUTC: { id: 'course-authoring.course-outline.configure-modal.basic-tab.due-time-UTC', - defaultMessage: 'Due Time in UTC:', + defaultMessage: 'Due time in UTC:', }, subsectionVisibility: { id: 'course-authoring.course-outline.configure-modal.visibility-tab.subsection-visibility', - defaultMessage: 'Subsection Visibility', + defaultMessage: 'Subsection visibility', }, showEntireSubsection: { id: 'course-authoring.course-outline.configure-modal.visibility-tab.show-entire-subsection', @@ -151,7 +161,7 @@ const messages = defineMessages({ }, setSpecialExam: { id: 'course-authoring.course-outline.configure-modal.advanced-tab.set-special-exam', - defaultMessage: 'Set as a Special Exam', + defaultMessage: 'Set as a special exam', }, none: { id: 'course-authoring.course-outline.configure-modal.advanced-tab.none', @@ -195,7 +205,7 @@ const messages = defineMessages({ }, timeAllotted: { id: 'course-authoring.course-outline.configure-modal.advanced-tab.time-allotted', - defaultMessage: 'Time Allotted (HH:MM):', + defaultMessage: 'Time allotted (HH:MM):', }, timeLimitDescription: { id: 'course-authoring.course-outline.configure-modal.advanced-tab.time-limit-description', diff --git a/src/generic/prompt-if-dirty/PromptIfDirty.jsx b/src/generic/prompt-if-dirty/PromptIfDirty.jsx new file mode 100644 index 0000000000..a686ea2e87 --- /dev/null +++ b/src/generic/prompt-if-dirty/PromptIfDirty.jsx @@ -0,0 +1,24 @@ +import { useEffect } from 'react'; +import PropTypes from 'prop-types'; + +const PromptIfDirty = ({ dirty }) => { + useEffect(() => { + // eslint-disable-next-line consistent-return + const handleBeforeUnload = (event) => { + if (dirty) { + event.preventDefault(); + } + }; + window.addEventListener('beforeunload', handleBeforeUnload); + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + }, [dirty]); + + return null; +}; +PromptIfDirty.propTypes = { + dirty: PropTypes.bool.isRequired, +}; +export default PromptIfDirty; diff --git a/src/generic/prompt-if-dirty/PromptIfDirty.test.jsx b/src/generic/prompt-if-dirty/PromptIfDirty.test.jsx new file mode 100644 index 0000000000..b429a7e137 --- /dev/null +++ b/src/generic/prompt-if-dirty/PromptIfDirty.test.jsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { act } from 'react-dom/test-utils'; +import PromptIfDirty from './PromptIfDirty'; + +describe('PromptIfDirty', () => { + let container = null; + let mockEvent = null; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + mockEvent = new Event('beforeunload'); + jest.spyOn(window, 'addEventListener'); + jest.spyOn(window, 'removeEventListener'); + jest.spyOn(mockEvent, 'preventDefault'); + Object.defineProperty(mockEvent, 'returnValue', { writable: true }); + mockEvent.returnValue = ''; + }); + + afterEach(() => { + window.addEventListener.mockRestore(); + window.removeEventListener.mockRestore(); + mockEvent.preventDefault.mockRestore(); + mockEvent = null; + unmountComponentAtNode(container); + container.remove(); + container = null; + }); + + it('should add event listener on mount', () => { + act(() => { + render(, container); + }); + + expect(window.addEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function)); + }); + + it('should remove event listener on unmount', () => { + act(() => { + render(, container); + }); + act(() => { + unmountComponentAtNode(container); + }); + + expect(window.removeEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function)); + }); + + it('should call preventDefault and set returnValue when dirty is true', () => { + act(() => { + render(, container); + }); + act(() => { + window.dispatchEvent(mockEvent); + }); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(mockEvent.returnValue).toBe(''); + }); + + it('should not call preventDefault when dirty is false', () => { + act(() => { + render(, container); + }); + act(() => { + window.dispatchEvent(mockEvent); + }); + + expect(mockEvent.preventDefault).not.toHaveBeenCalled(); + }); +}); diff --git a/src/generic/styles.scss b/src/generic/styles.scss index 71166f7f1b..0ef6a6202e 100644 --- a/src/generic/styles.scss +++ b/src/generic/styles.scss @@ -8,3 +8,4 @@ @import "./course-stepper/CouseStepper"; @import "./tag-count/TagCount"; @import "./modal-dropzone/ModalDropzone"; +@import "./configure-modal/ConfigureModal"; diff --git a/src/group-configurations/GroupConfigurations.scss b/src/group-configurations/GroupConfigurations.scss new file mode 100644 index 0000000000..cc1620a814 --- /dev/null +++ b/src/group-configurations/GroupConfigurations.scss @@ -0,0 +1,130 @@ +@import "./empty-placeholder/EmptyPlaceholder"; + +.configuration-section-name { + text-transform: lowercase; + + &::first-letter { + text-transform: capitalize; + } + + .group-percentage-container { + width: 1rem; + } +} + +.configuration-card { + @include pgn-box-shadow(1, "down"); + + background: $white; + border-radius: .375rem; + padding: map-get($spacers, 4); + margin-bottom: map-get($spacers, 4); + + .configuration-card-header { + display: flex; + align-items: center; + align-content: center; + justify-content: space-between; + + .configuration-card-header__button { + display: flex; + align-items: flex-start; + padding: 0; + height: auto; + color: $black; + + &:focus::before { + display: none; + } + + .pgn__icon { + display: inline-block; + margin-right: map-get($spacers, 1); + margin-bottom: map-get($spacers, 2\.5); + } + + .pgn__hstack { + align-items: baseline; + } + + &:hover { + background: transparent; + } + } + + .configuration-card-header__title { + text-align: left; + + h3 { + margin-bottom: map-get($spacers, 2); + } + } + + .configuration-card-header__badge { + display: flex; + padding: .125rem map-get($spacers, 2); + justify-content: center; + align-items: center; + border-radius: $border-radius; + border: .063rem solid $light-300; + background: $white; + + &:first-child { + margin-left: map-get($spacers, 2\.5); + } + + & span:last-child { + color: $primary-700; + } + } + + .configuration-card-header__delete-tooltip { + pointer-events: all; + } + } + + .configuration-card-content { + margin: 0 map-get($spacers, 2) 0 map-get($spacers, 4); + + .configuration-card-content__experiment-stack { + display: flex; + justify-content: space-between; + padding: map-get($spacers, 2\.5) 0; + margin: 0; + color: $primary-500; + gap: $spacer; + + &:not(:last-child) { + border-bottom: .063rem solid $light-400; + } + } + } + + .pgn__form-control-decorator-group { + margin-inline-end: 0; + } + + .configuration-form-group { + .pgn__form-label { + font: normal $font-weight-bold .875rem/1.25rem $font-family-base; + color: $gray-700; + margin-bottom: .875rem; + } + + .pgn__form-control-description, + .pgn__form-text { + font: normal $font-weight-normal .75rem/1.25rem $font-family-base; + color: $gray-500; + margin-top: .625rem; + } + + .pgn__form-text-invalid { + color: $form-feedback-invalid-color; + } + } + + .experiment-configuration-form-percentage { + width: 5rem; + text-align: center; + } +} diff --git a/src/group-configurations/GroupConfigurations.test.jsx b/src/group-configurations/GroupConfigurations.test.jsx new file mode 100644 index 0000000000..34486c368b --- /dev/null +++ b/src/group-configurations/GroupConfigurations.test.jsx @@ -0,0 +1,106 @@ +import MockAdapter from 'axios-mock-adapter'; +import { render, waitFor, within } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { RequestStatus } from '../data/constants'; +import initializeStore from '../store'; +import { executeThunk } from '../utils'; +import { getContentStoreApiUrl } from './data/api'; +import { fetchGroupConfigurationsQuery } from './data/thunk'; +import { groupConfigurationResponseMock } from './__mocks__'; +import messages from './messages'; +import experimentMessages from './experiment-configurations-section/messages'; +import contentGroupsMessages from './content-groups-section/messages'; +import GroupConfigurations from '.'; + +let axiosMock; +let store; +const courseId = 'course-v1:org+101+101'; +const enrollmentTrackGroups = groupConfigurationResponseMock.allGroupConfigurations[0]; +const contentGroups = groupConfigurationResponseMock.allGroupConfigurations[1]; + +const renderComponent = () => render( + + + + + , +); + +describe('', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock + .onGet(getContentStoreApiUrl(courseId)) + .reply(200, groupConfigurationResponseMock); + await executeThunk(fetchGroupConfigurationsQuery(courseId), store.dispatch); + }); + + it('renders component correctly', async () => { + const { getByText, getAllByText, getByTestId } = renderComponent(); + + await waitFor(() => { + const mainContent = getByTestId('group-configurations-main-content-wrapper'); + const groupConfigurationsElements = getAllByText(messages.headingTitle.defaultMessage); + const groupConfigurationsTitle = groupConfigurationsElements[0]; + + expect(groupConfigurationsTitle).toBeInTheDocument(); + expect( + getByText(messages.headingSubtitle.defaultMessage), + ).toBeInTheDocument(); + expect( + within(mainContent).getByText(contentGroupsMessages.addNewGroup.defaultMessage), + ).toBeInTheDocument(); + expect( + within(mainContent).getByText(experimentMessages.addNewGroup.defaultMessage), + ).toBeInTheDocument(); + expect( + within(mainContent).getByText(experimentMessages.title.defaultMessage), + ).toBeInTheDocument(); + expect(getByText(contentGroups.name)).toBeInTheDocument(); + expect(getByText(enrollmentTrackGroups.name)).toBeInTheDocument(); + }); + }); + + it('does not render an empty section for enrollment track groups if it is empty', () => { + const shouldNotShowEnrollmentTrackResponse = { + ...groupConfigurationResponseMock, + shouldShowEnrollmentTrack: false, + }; + axiosMock + .onGet(getContentStoreApiUrl(courseId)) + .reply(200, shouldNotShowEnrollmentTrackResponse); + + const { queryByTestId } = renderComponent(); + expect( + queryByTestId('group-configurations-empty-placeholder'), + ).not.toBeInTheDocument(); + }); + + it('updates loading status if request fails', async () => { + axiosMock + .onGet(getContentStoreApiUrl(courseId)) + .reply(404, groupConfigurationResponseMock); + + renderComponent(); + + await executeThunk(fetchGroupConfigurationsQuery(courseId), store.dispatch); + + expect(store.getState().groupConfigurations.loadingStatus).toBe( + RequestStatus.FAILED, + ); + }); +}); diff --git a/src/group-configurations/__mocks__/contentGroupsMock.js b/src/group-configurations/__mocks__/contentGroupsMock.js new file mode 100644 index 0000000000..3f2ea7be21 --- /dev/null +++ b/src/group-configurations/__mocks__/contentGroupsMock.js @@ -0,0 +1,44 @@ +module.exports = { + active: true, + description: 'The groups in this configuration can be mapped to cohorts in the Instructor Dashboard.', + groups: [ + { + id: 593758473, + name: 'My Content Group 1', + usage: [], + version: 1, + }, + { + id: 256741177, + name: 'My Content Group 2', + usage: [ + { + label: 'Unit / Blank Problem', + url: '/container/block-v1:org+101+101+type@vertical+block@3d6d82348e2743b6ac36ac4af354de0e', + }, + { + label: 'Unit / Drag and Drop', + url: '/container/block-v1:org+101+101+type@vertical+block@3d6d82348w2743b6ac36ac4af354de0e', + }, + ], + version: 1, + }, + { + id: 646686987, + name: 'My Content Group 3', + usage: [ + { + label: 'Unit / Drag and Drop', + url: '/container/block-v1:org+101+101+type@vertical+block@3d6d82348e2743b6ac36ac4af354de0e', + }, + ], + version: 1, + }, + ], + id: 1791848226, + name: 'Content Groups', + parameters: {}, + readOnly: false, + scheme: 'cohort', + version: 3, +}; diff --git a/src/group-configurations/__mocks__/enrollmentTrackGroupsMock.js b/src/group-configurations/__mocks__/enrollmentTrackGroupsMock.js new file mode 100644 index 0000000000..654ff900f2 --- /dev/null +++ b/src/group-configurations/__mocks__/enrollmentTrackGroupsMock.js @@ -0,0 +1,32 @@ +module.exports = { + active: true, + description: 'Partition for segmenting users by enrollment track', + groups: [ + { + id: 6, + name: '1111', + usage: [], + version: 1, + }, + { + id: 2, + name: 'Enrollment track group', + usage: [ + { + label: 'Subsection / Unit', + url: '/container/block-v1:org+101+101+type@vertical+block@08772238547242848cef928ba6446a55', + }, + ], + version: 1, + }, + ], + id: 50, + usage: null, + name: 'Enrollment Track Groups', + parameters: { + course_id: 'course-v1:org+101+101', + }, + read_only: true, + scheme: 'enrollment_track', + version: 3, +}; diff --git a/src/group-configurations/__mocks__/experimentGroupConfigurationsMock.js b/src/group-configurations/__mocks__/experimentGroupConfigurationsMock.js new file mode 100644 index 0000000000..ab2356e744 --- /dev/null +++ b/src/group-configurations/__mocks__/experimentGroupConfigurationsMock.js @@ -0,0 +1,79 @@ +module.exports = [ + { + active: true, + description: 'description', + groups: [ + { + id: 276408623, + name: 'Group A', + usage: null, + version: 1, + }, + { + id: 805061364, + name: 'Group B', + usage: null, + version: 1, + }, + { + id: 1919501026, + name: 'Group C1', + usage: null, + version: 1, + }, + ], + id: 875961582, + name: 'Experiment Group Configurations 1', + parameters: {}, + scheme: 'random', + version: 3, + usage: [ + { + label: 'Unit1name / Content Experiment', + url: '/container/block-v1:2u+1+1+type@split_test+block@ccfae830ec9b406c835f8ce4520ae395', + }, + ], + }, + { + active: true, + description: 'description', + groups: [ + { + id: 1712898629, + name: 'Group M', + usage: null, + version: 1, + }, + { + id: 374655043, + name: 'Group N', + usage: null, + version: 1, + }, + { + id: 997016182, + name: 'Group O', + usage: null, + version: 1, + }, + { + id: 361314468, + name: 'Group P', + usage: null, + version: 1, + }, + { + id: 505101805, + name: 'Group Q', + usage: null, + version: 1, + }, + ], + id: 996450752, + name: 'Experiment Group Configurations 2', + parameters: {}, + scheme: 'random', + version: 3, + usage: [], + }, +]; diff --git a/src/group-configurations/__mocks__/groupConfigurationResponseMock.js b/src/group-configurations/__mocks__/groupConfigurationResponseMock.js new file mode 100644 index 0000000000..b7f5540697 --- /dev/null +++ b/src/group-configurations/__mocks__/groupConfigurationResponseMock.js @@ -0,0 +1,149 @@ +module.exports = { + allGroupConfigurations: [ + { + active: true, + description: 'Partition for segmenting users by enrollment track', + groups: [ + { + id: 6, + name: '1111', + usage: [], + version: 1, + }, + { + id: 2, + name: 'Enrollment track group', + usage: [ + { + label: 'Subsection / Unit', + url: '/container/block-v1:org+101+101+type@vertical+block@08772238547242848cef928ba6446a55', + }, + ], + version: 1, + }, + ], + id: 50, + usage: null, + name: 'Enrollment Track Groups', + parameters: { + course_id: 'course-v1:org+101+101', + }, + read_only: true, + scheme: 'enrollment_track', + version: 3, + }, + { + active: true, + description: + 'The groups in this configuration can be mapped to cohorts in the Instructor Dashboard.', + groups: [ + { + id: 593758473, + name: 'My Content Group 1', + usage: [], + version: 1, + }, + { + id: 256741177, + name: 'My Content Group 2', + usage: [], + version: 1, + }, + { + id: 646686987, + name: 'My Content Group 3', + usage: [ + { + label: 'Unit / Drag and Drop', + url: '/container/block-v1:org+101+101+type@vertical+block@3d6d82348e2743b6ac36ac4af354de0e', + }, + ], + version: 1, + }, + ], + id: 1791848226, + name: 'Content Groups', + parameters: {}, + readOnly: false, + scheme: 'cohort', + version: 3, + }, + ], + experimentGroupConfigurations: [ + { + active: true, + description: 'description', + groups: [ + { + id: 276408623, + name: 'Group A', + usage: null, + version: 1, + }, + { + id: 805061364, + name: 'Group B', + usage: null, + version: 1, + }, + { + id: 1919501026, + name: 'Group C1', + usage: null, + version: 1, + }, + ], + id: 875961582, + name: 'Experiment Group Configurations 5', + parameters: {}, + scheme: 'random', + version: 3, + usage: [], + }, + { + active: true, + description: 'description', + groups: [ + { + id: 1712898629, + name: 'Group M', + usage: null, + version: 1, + }, + { + id: 374655043, + name: 'Group N', + usage: null, + version: 1, + }, + { + id: 997016182, + name: 'Group O', + usage: null, + version: 1, + }, + { + id: 361314468, + name: 'Group P', + usage: null, + version: 1, + }, + { + id: 505101805, + name: 'Group Q', + usage: null, + version: 1, + }, + ], + id: 996450752, + name: 'Experiment Group Configurations 4', + parameters: {}, + scheme: 'random', + version: 3, + usage: [], + }, + ], + mfeProctoredExamSettingsUrl: '', + shouldShowEnrollmentTrack: true, + shouldShowExperimentGroups: true, +}; diff --git a/src/group-configurations/__mocks__/index.js b/src/group-configurations/__mocks__/index.js new file mode 100644 index 0000000000..bb3f889849 --- /dev/null +++ b/src/group-configurations/__mocks__/index.js @@ -0,0 +1,4 @@ +export { default as contentGroupsMock } from './contentGroupsMock'; +export { default as enrollmentTrackGroupsMock } from './enrollmentTrackGroupsMock'; +export { default as experimentGroupConfigurationsMock } from './experimentGroupConfigurationsMock'; +export { default as groupConfigurationResponseMock } from './groupConfigurationResponseMock'; diff --git a/src/group-configurations/common/TitleButton.jsx b/src/group-configurations/common/TitleButton.jsx new file mode 100644 index 0000000000..87d5d50016 --- /dev/null +++ b/src/group-configurations/common/TitleButton.jsx @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Button, Stack, Badge, Truncate, +} from '@openedx/paragon'; +import { + ArrowDropDown as ArrowDownIcon, + ArrowRight as ArrowRightIcon, +} from '@openedx/paragon/icons'; + +import { getCombinedBadgeList } from '../utils'; +import messages from './messages'; + +const TitleButton = ({ + group, isExpanded, isExperiment, onTitleClick, +}) => { + const { formatMessage } = useIntl(); + const { id, name, usage } = group; + + return ( + + ); +}; + +TitleButton.defaultProps = { + isExperiment: false, +}; + +TitleButton.propTypes = { + group: PropTypes.shape({ + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + usage: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + url: PropTypes.string, + }), + ), + version: PropTypes.number.isRequired, + active: PropTypes.bool, + description: PropTypes.string, + groups: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.number, + name: PropTypes.string, + usage: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + url: PropTypes.string, + }), + ), + version: PropTypes.number, + }), + ), + parameters: PropTypes.shape({ + courseId: PropTypes.string, + }), + readOnly: PropTypes.bool, + scheme: PropTypes.string, + }).isRequired, + isExpanded: PropTypes.bool.isRequired, + isExperiment: PropTypes.bool, + onTitleClick: PropTypes.func.isRequired, +}; + +export default TitleButton; diff --git a/src/group-configurations/common/UsageList.jsx b/src/group-configurations/common/UsageList.jsx new file mode 100644 index 0000000000..5c6287a9d6 --- /dev/null +++ b/src/group-configurations/common/UsageList.jsx @@ -0,0 +1,73 @@ +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Hyperlink, Stack, Icon } from '@openedx/paragon'; +import { + Warning as WarningIcon, + Error as ErrorIcon, +} from '@openedx/paragon/icons'; + +import { MESSAGE_VALIDATION_TYPES } from '../constants'; +import { formatUrlToUnitPage } from '../utils'; +import messages from './messages'; + +const UsageList = ({ className, itemList, isExperiment }) => { + const { formatMessage } = useIntl(); + const usageDescription = isExperiment + ? messages.experimentAccessTo + : messages.accessTo; + + const renderValidationMessage = ({ text, type }) => ( + + + {text} + + ); + + return ( +
+

+ {formatMessage(usageDescription)} +

+ + {itemList.map(({ url, label, validation }) => ( + <> + + {label} + + {validation && renderValidationMessage(validation)} + + ))} + +
+ ); +}; + +UsageList.defaultProps = { + className: undefined, + isExperiment: false, +}; + +UsageList.propTypes = { + className: PropTypes.string, + itemList: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + url: PropTypes.string, + validation: PropTypes.shape({ + text: PropTypes.string, + type: PropTypes.string, + }), + }).isRequired, + ).isRequired, + isExperiment: PropTypes.bool, +}; + +export default UsageList; diff --git a/src/group-configurations/common/UsageList.test.jsx b/src/group-configurations/common/UsageList.test.jsx new file mode 100644 index 0000000000..e4d4681279 --- /dev/null +++ b/src/group-configurations/common/UsageList.test.jsx @@ -0,0 +1,34 @@ +import { render } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import { contentGroupsMock } from '../__mocks__'; +import { formatUrlToUnitPage } from '../utils'; +import UsageList from './UsageList'; +import messages from './messages'; + +const usages = contentGroupsMock.groups[1]?.usage; + +const renderComponent = (props = {}) => render( + + + , +); + +describe('', () => { + it('renders component correctly', () => { + const { getByText, getAllByRole } = renderComponent(); + expect(getByText(messages.accessTo.defaultMessage)).toBeInTheDocument(); + expect(getAllByRole('link')).toHaveLength(2); + getAllByRole('link').forEach((el, idx) => { + expect(el.href).toMatch(formatUrlToUnitPage(usages[idx].url)); + expect(getByText(usages[idx].label)).toBeVisible(); + }); + }); + + it('renders experiment component correctly', () => { + const { getByText } = renderComponent({ isExperiment: true }); + expect( + getByText(messages.experimentAccessTo.defaultMessage), + ).toBeInTheDocument(); + }); +}); diff --git a/src/group-configurations/common/index.js b/src/group-configurations/common/index.js new file mode 100644 index 0000000000..0089b3865f --- /dev/null +++ b/src/group-configurations/common/index.js @@ -0,0 +1,2 @@ +export { default as TitleButton } from './TitleButton'; +export { default as UsageList } from './UsageList'; diff --git a/src/group-configurations/common/messages.js b/src/group-configurations/common/messages.js new file mode 100644 index 0000000000..708b376b08 --- /dev/null +++ b/src/group-configurations/common/messages.js @@ -0,0 +1,21 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + titleId: { + id: 'course-authoring.group-configurations.container.title-id', + defaultMessage: 'ID: {id}', + description: 'Message for the title of a container within group configurations section', + }, + accessTo: { + id: 'course-authoring.group-configurations.container.access-to', + defaultMessage: 'This group controls access to:', + description: 'Indicates that the units are contained in content group', + }, + experimentAccessTo: { + id: 'course-authoring.group-configurations.experiment-card.experiment-access-to', + defaultMessage: 'This group configuration is used in:', + description: 'Indicates that the units are contained in experiment configurations', + }, +}); + +export default messages; diff --git a/src/group-configurations/constants.js b/src/group-configurations/constants.js new file mode 100644 index 0000000000..7e87fc8628 --- /dev/null +++ b/src/group-configurations/constants.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; + +const availableGroupPropTypes = { + active: PropTypes.bool, + description: PropTypes.string, + groups: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.number, + name: PropTypes.string, + usage: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + url: PropTypes.string, + }), + ), + version: PropTypes.number, + }), + ), + id: PropTypes.number, + name: PropTypes.string, + parameters: PropTypes.shape({ + courseId: PropTypes.string, + }), + readOnly: PropTypes.bool, + scheme: PropTypes.string, + version: PropTypes.number, +}; + +const MESSAGE_VALIDATION_TYPES = { + error: 'error', + warning: 'warning', +}; + +export { MESSAGE_VALIDATION_TYPES, availableGroupPropTypes }; diff --git a/src/group-configurations/content-groups-section/ContentGroupCard.jsx b/src/group-configurations/content-groups-section/ContentGroupCard.jsx new file mode 100644 index 0000000000..e56d4d4c4c --- /dev/null +++ b/src/group-configurations/content-groups-section/ContentGroupCard.jsx @@ -0,0 +1,200 @@ +import { useState } from 'react'; +import PropTypes from 'prop-types'; +import { useParams } from 'react-router-dom'; +import { getConfig } from '@edx/frontend-platform'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + ActionRow, + Hyperlink, + Icon, + IconButtonWithTooltip, + useToggle, +} from '@openedx/paragon'; +import { + DeleteOutline as DeleteOutlineIcon, + EditOutline as EditOutlineIcon, +} from '@openedx/paragon/icons'; + +import DeleteModal from '../../generic/delete-modal/DeleteModal'; +import TitleButton from '../common/TitleButton'; +import UsageList from '../common/UsageList'; +import ContentGroupForm from './ContentGroupForm'; +import messages from './messages'; + +const ContentGroupCard = ({ + group, + groupNames, + parentGroupId, + readOnly, + contentGroupActions, + handleEditGroup, +}) => { + const { formatMessage } = useIntl(); + const { courseId } = useParams(); + const [isExpanded, setIsExpanded] = useState(false); + const [isEditMode, switchOnEditMode, switchOffEditMode] = useToggle(false); + const [isOpenDeleteModal, openDeleteModal, closeDeleteModal] = useToggle(false); + const { id, name, usage } = group; + const isUsedInLocation = !!usage.length; + + const { href: outlineUrl } = new URL( + `/course/${courseId}`, + getConfig().STUDIO_BASE_URL, + ); + + const outlineComponentLink = ( + + {formatMessage(messages.courseOutline)} + + ); + + const guideHowToAdd = ( + + {formatMessage(messages.emptyContentGroups, { outlineComponentLink })} + + ); + + const handleExpandContent = () => { + setIsExpanded((prevState) => !prevState); + }; + + const handleDeleteGroup = () => { + contentGroupActions.handleDelete(parentGroupId, id); + closeDeleteModal(); + }; + + return ( + <> + {isEditMode ? ( + handleEditGroup(id, values, switchOffEditMode)} + /> + ) : ( +
+
+ + {!readOnly && ( + + + + + )} +
+ {isExpanded && ( +
+ {usage?.length ? ( + + ) : ( + guideHowToAdd + )} +
+ )} +
+ )} + + + ); +}; + +ContentGroupCard.defaultProps = { + group: { + id: undefined, + name: '', + usage: [], + version: undefined, + }, + readOnly: false, + groupNames: [], + parentGroupId: null, + handleEditGroup: null, + contentGroupActions: {}, +}; + +ContentGroupCard.propTypes = { + group: PropTypes.shape({ + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + usage: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + url: PropTypes.string, + }), + ), + version: PropTypes.number.isRequired, + active: PropTypes.bool, + description: PropTypes.string, + groups: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.number, + name: PropTypes.string, + usage: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + url: PropTypes.string, + }), + ), + version: PropTypes.number, + }), + ), + parameters: PropTypes.shape({ + courseId: PropTypes.string, + }), + readOnly: PropTypes.bool, + scheme: PropTypes.string, + }), + groupNames: PropTypes.arrayOf(PropTypes.string), + parentGroupId: PropTypes.number, + readOnly: PropTypes.bool, + handleEditGroup: PropTypes.func, + contentGroupActions: PropTypes.shape({ + handleCreate: PropTypes.func, + handleDelete: PropTypes.func, + handleEdit: PropTypes.func, + }), +}; + +export default ContentGroupCard; diff --git a/src/group-configurations/content-groups-section/ContentGroupCard.test.jsx b/src/group-configurations/content-groups-section/ContentGroupCard.test.jsx new file mode 100644 index 0000000000..10d259f0e3 --- /dev/null +++ b/src/group-configurations/content-groups-section/ContentGroupCard.test.jsx @@ -0,0 +1,93 @@ +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import { contentGroupsMock } from '../__mocks__'; +import commonMessages from '../common/messages'; +import rootMessages from '../messages'; +import ContentGroupCard from './ContentGroupCard'; + +const handleCreateMock = jest.fn(); +const handleDeleteMock = jest.fn(); +const handleEditMock = jest.fn(); +const contentGroupActions = { + handleCreate: handleCreateMock, + handleDelete: handleDeleteMock, + handleEdit: handleEditMock, +}; + +const handleEditGroupMock = jest.fn(); +const contentGroup = contentGroupsMock.groups[0]; +const contentGroupWithUsages = contentGroupsMock.groups[1]; +const contentGroupWithOnlyOneUsage = contentGroupsMock.groups[2]; + +const renderComponent = (props = {}) => render( + + group.name)} + parentGroupId={contentGroupsMock.id} + contentGroupActions={contentGroupActions} + handleEditGroup={handleEditGroupMock} + {...props} + /> + , +); + +describe('', () => { + it('renders component correctly', () => { + const { getByText, getByTestId } = renderComponent(); + expect(getByText(contentGroup.name)).toBeInTheDocument(); + expect( + getByText( + commonMessages.titleId.defaultMessage.replace('{id}', contentGroup.id), + ), + ).toBeInTheDocument(); + expect(getByText(rootMessages.notInUse.defaultMessage)).toBeInTheDocument(); + expect(getByTestId('content-group-card-header-edit')).toBeInTheDocument(); + expect(getByTestId('content-group-card-header-delete')).toBeInTheDocument(); + }); + + it('expands/collapses the container group content on title click', () => { + const { + getByText, queryByTestId, getByTestId, queryByText, + } = renderComponent(); + const cardTitle = getByTestId('configuration-card-header-button'); + userEvent.click(cardTitle); + expect(queryByTestId('content-group-card-content')).toBeInTheDocument(); + expect( + queryByText(rootMessages.notInUse.defaultMessage), + ).not.toBeInTheDocument(); + + userEvent.click(cardTitle); + expect(queryByTestId('content-group-card-content')).not.toBeInTheDocument(); + expect(getByText(rootMessages.notInUse.defaultMessage)).toBeInTheDocument(); + }); + + it('renders content group badge with used only one location', () => { + const { queryByTestId } = renderComponent({ + group: contentGroupWithOnlyOneUsage, + }); + const usageBlock = queryByTestId('configuration-card-header-button-usage'); + expect(usageBlock).toBeInTheDocument(); + }); + + it('renders content group badge with used locations', () => { + const { queryByTestId } = renderComponent({ + group: contentGroupWithUsages, + }); + const usageBlock = queryByTestId('configuration-card-header-button-usage'); + expect(usageBlock).toBeInTheDocument(); + }); + + it('renders group controls without access to units', () => { + const { queryByText, getByTestId } = renderComponent(); + expect( + queryByText(commonMessages.accessTo.defaultMessage), + ).not.toBeInTheDocument(); + + const cardTitle = getByTestId('configuration-card-header-button'); + userEvent.click(cardTitle); + expect(getByTestId('configuration-card-usage-empty')).toBeInTheDocument(); + }); +}); diff --git a/src/group-configurations/content-groups-section/ContentGroupForm.jsx b/src/group-configurations/content-groups-section/ContentGroupForm.jsx new file mode 100644 index 0000000000..b4f7a76ba2 --- /dev/null +++ b/src/group-configurations/content-groups-section/ContentGroupForm.jsx @@ -0,0 +1,123 @@ +import PropTypes from 'prop-types'; +import { Formik } from 'formik'; +import * as Yup from 'yup'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Alert, + ActionRow, + Button, + Form, +} from '@openedx/paragon'; + +import { WarningFilled as WarningFilledIcon } from '@openedx/paragon/icons'; + +import PromptIfDirty from '../../generic/prompt-if-dirty/PromptIfDirty'; +import { isAlreadyExistsGroup } from './utils'; +import messages from './messages'; + +const ContentGroupForm = ({ + isEditMode, + groupNames, + isUsedInLocation, + overrideValue, + onCreateClick, + onCancelClick, + onEditClick, +}) => { + const { formatMessage } = useIntl(); + const initialValues = { newGroupName: overrideValue }; + const validationSchema = Yup.object().shape({ + newGroupName: Yup.string() + .required(formatMessage(messages.requiredError)) + .trim() + .test( + 'unique-name-restriction', + formatMessage(messages.invalidMessage), + (value) => overrideValue === value || !isAlreadyExistsGroup(groupNames, value), + ), + }); + const onSubmitForm = isEditMode ? onEditClick : onCreateClick; + + return ( +
+
+

{formatMessage(messages.newGroupHeader)}

+
+ + {({ + values, errors, dirty, handleChange, handleSubmit, + }) => { + const isInvalid = !!errors.newGroupName; + + return ( + <> + + + {isInvalid && ( + + {errors.newGroupName} + + )} + + {isUsedInLocation && ( + +

{formatMessage(messages.alertGroupInUsage)}

+
+ )} + + + + + + + ); + }} +
+
+ ); +}; + +ContentGroupForm.defaultProps = { + groupNames: [], + overrideValue: '', + isEditMode: false, + isUsedInLocation: false, + onCreateClick: null, + onEditClick: null, +}; + +ContentGroupForm.propTypes = { + groupNames: PropTypes.arrayOf(PropTypes.string), + isEditMode: PropTypes.bool, + isUsedInLocation: PropTypes.bool, + overrideValue: PropTypes.string, + onCreateClick: PropTypes.func, + onCancelClick: PropTypes.func.isRequired, + onEditClick: PropTypes.func, +}; + +export default ContentGroupForm; diff --git a/src/group-configurations/content-groups-section/ContentGroupForm.test.jsx b/src/group-configurations/content-groups-section/ContentGroupForm.test.jsx new file mode 100644 index 0000000000..22826daf63 --- /dev/null +++ b/src/group-configurations/content-groups-section/ContentGroupForm.test.jsx @@ -0,0 +1,176 @@ +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import userEvent from '@testing-library/user-event'; +import { render, waitFor } from '@testing-library/react'; + +import { contentGroupsMock } from '../__mocks__'; +import messages from './messages'; +import ContentGroupForm from './ContentGroupForm'; + +const onCreateClickMock = jest.fn(); +const onCancelClickMock = jest.fn(); +const onEditClickMock = jest.fn(); + +const renderComponent = (props = {}) => render( + + group.name)} + onCreateClick={onCreateClickMock} + onCancelClick={onCancelClickMock} + onEditClick={onEditClickMock} + {...props} + /> + , +); + +describe('', () => { + it('renders component correctly', () => { + const { getByText, getByRole, getByTestId } = renderComponent(); + + expect(getByTestId('content-group-form')).toBeInTheDocument(); + expect( + getByText(messages.newGroupHeader.defaultMessage), + ).toBeInTheDocument(); + expect( + getByRole('button', { name: messages.cancelButton.defaultMessage }), + ).toBeInTheDocument(); + expect( + getByRole('button', { name: messages.createButton.defaultMessage }), + ).toBeInTheDocument(); + }); + + it('renders component in edit mode', () => { + const { + getByText, queryByText, getByRole, getByPlaceholderText, + } = renderComponent({ + isEditMode: true, + overrideValue: 'overrideValue', + }); + const newGroupInput = getByPlaceholderText( + messages.newGroupInputPlaceholder.defaultMessage, + ); + + expect(newGroupInput).toBeInTheDocument(); + expect( + getByText(messages.newGroupHeader.defaultMessage), + ).toBeInTheDocument(); + expect( + getByRole('button', { name: messages.saveButton.defaultMessage }), + ).toBeInTheDocument(); + expect( + queryByText(messages.alertGroupInUsage.defaultMessage), + ).not.toBeInTheDocument(); + }); + + it('shows alert if group is used in location with edit mode', () => { + const { getByText } = renderComponent({ + isEditMode: true, + overrideValue: 'overrideValue', + isUsedInLocation: true, + }); + expect( + getByText(messages.alertGroupInUsage.defaultMessage), + ).toBeInTheDocument(); + }); + + it('calls onCreate when the "Create" button is clicked with a valid form', async () => { + const { + getByRole, getByPlaceholderText, queryByText, + } = renderComponent(); + const newGroupNameText = 'New group name'; + const newGroupInput = getByPlaceholderText( + messages.newGroupInputPlaceholder.defaultMessage, + ); + userEvent.type(newGroupInput, newGroupNameText); + const createButton = getByRole('button', { + name: messages.createButton.defaultMessage, + }); + expect(createButton).toBeInTheDocument(); + userEvent.click(createButton); + + await waitFor(() => { + expect(onCreateClickMock).toHaveBeenCalledTimes(1); + }); + expect( + queryByText(messages.requiredError.defaultMessage), + ).not.toBeInTheDocument(); + }); + + it('shows error when the "Create" button is clicked with an invalid form', async () => { + const { getByRole, getByPlaceholderText, getByText } = renderComponent(); + const newGroupNameText = ''; + const newGroupInput = getByPlaceholderText( + messages.newGroupInputPlaceholder.defaultMessage, + ); + userEvent.type(newGroupInput, newGroupNameText); + const createButton = getByRole('button', { + name: messages.createButton.defaultMessage, + }); + expect(createButton).toBeInTheDocument(); + userEvent.click(createButton); + + await waitFor(() => { + expect( + getByText(messages.requiredError.defaultMessage), + ).toBeInTheDocument(); + }); + }); + + it('calls onEdit when the "Save" button is clicked with a valid form', async () => { + const { getByRole, getByPlaceholderText, queryByText } = renderComponent({ + isEditMode: true, + overrideValue: 'overrideValue', + }); + const newGroupNameText = 'Updated group name'; + const newGroupInput = getByPlaceholderText( + messages.newGroupInputPlaceholder.defaultMessage, + ); + userEvent.type(newGroupInput, newGroupNameText); + const saveButton = getByRole('button', { + name: messages.saveButton.defaultMessage, + }); + expect(saveButton).toBeInTheDocument(); + userEvent.click(saveButton); + + await waitFor(() => { + expect( + queryByText(messages.requiredError.defaultMessage), + ).not.toBeInTheDocument(); + }); + expect(onEditClickMock).toHaveBeenCalledTimes(1); + }); + + it('shows error when the "Save" button is clicked with an invalid duplicate form', async () => { + const { getByRole, getByPlaceholderText, getByText } = renderComponent({ + isEditMode: true, + overrideValue: contentGroupsMock.groups[0].name, + }); + const newGroupNameText = contentGroupsMock.groups[2].name; + const newGroupInput = getByPlaceholderText( + messages.newGroupInputPlaceholder.defaultMessage, + ); + userEvent.clear(newGroupInput); + userEvent.type(newGroupInput, newGroupNameText); + const saveButton = getByRole('button', { + name: messages.saveButton.defaultMessage, + }); + expect(saveButton).toBeInTheDocument(); + userEvent.click(saveButton); + + await waitFor(() => { + expect( + getByText(messages.invalidMessage.defaultMessage), + ).toBeInTheDocument(); + }); + }); + + it('calls onCancel when the "Cancel" button is clicked', async () => { + const { getByRole } = renderComponent(); + const cancelButton = getByRole('button', { + name: messages.cancelButton.defaultMessage, + }); + expect(cancelButton).toBeInTheDocument(); + userEvent.click(cancelButton); + + expect(onCancelClickMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/group-configurations/content-groups-section/ContentGroupsSection.test.jsx b/src/group-configurations/content-groups-section/ContentGroupsSection.test.jsx new file mode 100644 index 0000000000..bbd9ca280f --- /dev/null +++ b/src/group-configurations/content-groups-section/ContentGroupsSection.test.jsx @@ -0,0 +1,64 @@ +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import userEvent from '@testing-library/user-event'; +import { render } from '@testing-library/react'; + +import { contentGroupsMock } from '../__mocks__'; +import placeholderMessages from '../empty-placeholder/messages'; +import messages from './messages'; +import ContentGroupsSection from '.'; + +const handleCreateMock = jest.fn(); +const handleDeleteMock = jest.fn(); +const handleEditMock = jest.fn(); +const contentGroupActions = { + handleCreate: handleCreateMock, + handleDelete: handleDeleteMock, + handleEdit: handleEditMock, +}; + +const renderComponent = (props = {}) => render( + + + , +); + +describe('', () => { + it('renders component correctly', () => { + const { getByText, getByRole, getAllByTestId } = renderComponent(); + expect(getByText(contentGroupsMock.name)).toBeInTheDocument(); + expect( + getByRole('button', { name: messages.addNewGroup.defaultMessage }), + ).toBeInTheDocument(); + + expect(getAllByTestId('content-group-card')).toHaveLength( + contentGroupsMock.groups.length, + ); + }); + + it('renders empty section', () => { + const { getByTestId } = renderComponent({ availableGroup: {} }); + expect( + getByTestId('group-configurations-empty-placeholder'), + ).toBeInTheDocument(); + }); + + it('renders container with new group on create click if section is empty', async () => { + const { getByRole, getByTestId } = renderComponent({ availableGroup: {} }); + userEvent.click( + getByRole('button', { name: placeholderMessages.button.defaultMessage }), + ); + expect(getByTestId('content-group-form')).toBeInTheDocument(); + }); + + it('renders container with new group on create click if section has groups', async () => { + const { getByRole, getByTestId } = renderComponent(); + userEvent.click( + getByRole('button', { name: messages.addNewGroup.defaultMessage }), + ); + expect(getByTestId('content-group-form')).toBeInTheDocument(); + }); +}); diff --git a/src/group-configurations/content-groups-section/index.jsx b/src/group-configurations/content-groups-section/index.jsx new file mode 100644 index 0000000000..de21e323f1 --- /dev/null +++ b/src/group-configurations/content-groups-section/index.jsx @@ -0,0 +1,95 @@ +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Button, useToggle } from '@openedx/paragon'; +import { Add as AddIcon } from '@openedx/paragon/icons'; + +import { availableGroupPropTypes } from '../constants'; +import EmptyPlaceholder from '../empty-placeholder'; +import ContentGroupCard from './ContentGroupCard'; +import ContentGroupForm from './ContentGroupForm'; +import { initialContentGroupObject } from './utils'; +import messages from './messages'; + +const ContentGroupsSection = ({ + availableGroup, + contentGroupActions, +}) => { + const { formatMessage } = useIntl(); + const [isNewGroupVisible, openNewGroup, hideNewGroup] = useToggle(false); + const { id: parentGroupId, groups, name } = availableGroup; + const groupNames = groups?.map((group) => group.name); + + const handleCreateNewGroup = (values) => { + const updatedContentGroups = { + ...availableGroup, + groups: [ + ...availableGroup.groups, + initialContentGroupObject(values.newGroupName), + ], + }; + contentGroupActions.handleCreate(updatedContentGroups, hideNewGroup); + }; + + const handleEditContentGroup = (id, { newGroupName }, callbackToClose) => { + const updatedContentGroups = { + ...availableGroup, + groups: availableGroup.groups.map((group) => (group.id === id ? { ...group, name: newGroupName } : group)), + }; + contentGroupActions.handleEdit(updatedContentGroups, callbackToClose); + }; + + return ( +
+

+ {name} +

+ {groups?.length ? ( + <> + {groups.map((group) => ( + + ))} + {!isNewGroupVisible && ( + + )} + + ) : ( + !isNewGroupVisible && ( + + ) + )} + {isNewGroupVisible && ( + + )} +
+ ); +}; + +ContentGroupsSection.propTypes = { + availableGroup: PropTypes.shape(availableGroupPropTypes).isRequired, + contentGroupActions: PropTypes.shape({ + handleCreate: PropTypes.func, + handleDelete: PropTypes.func, + handleEdit: PropTypes.func, + }).isRequired, +}; + +export default ContentGroupsSection; diff --git a/src/group-configurations/content-groups-section/messages.js b/src/group-configurations/content-groups-section/messages.js new file mode 100644 index 0000000000..834b847900 --- /dev/null +++ b/src/group-configurations/content-groups-section/messages.js @@ -0,0 +1,86 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + addNewGroup: { + id: 'course-authoring.group-configurations.content-groups.add-new-group', + defaultMessage: 'New content group', + description: 'Label for adding a new content group.', + }, + newGroupHeader: { + id: 'course-authoring.group-configurations.content-groups.new-group.header', + defaultMessage: 'Content group name *', + description: 'Header text for the input field to enter the name of a new content group.', + }, + newGroupInputPlaceholder: { + id: 'course-authoring.group-configurations.content-groups.new-group.input.placeholder', + defaultMessage: 'This is the name of the group', + description: 'Placeholder text for the input field where the name of a new content group is entered.', + }, + invalidMessage: { + id: 'course-authoring.group-configurations.content-groups.new-group.invalid-message', + defaultMessage: 'All groups must have a unique name.', + description: 'Error message displayed when the name of the new content group is not unique.', + }, + cancelButton: { + id: 'course-authoring.group-configurations.content-groups.new-group.cancel', + defaultMessage: 'Cancel', + description: 'Label for the cancel button when creating a new content group.', + }, + deleteButton: { + id: 'course-authoring.group-configurations.content-groups.edit-group.delete', + defaultMessage: 'Delete', + description: 'Label for the delete button when editing a content group.', + }, + createButton: { + id: 'course-authoring.group-configurations.content-groups.new-group.create', + defaultMessage: 'Create', + description: 'Label for the create button when creating a new content group.', + }, + saveButton: { + id: 'course-authoring.group-configurations.content-groups.edit-group.save', + defaultMessage: 'Save', + description: 'Label for the save button when editing a content group.', + }, + requiredError: { + id: 'course-authoring.group-configurations.content-groups.new-group.required-error', + defaultMessage: 'Group name is required', + description: 'Error message displayed when the name of the content group is required but not provided.', + }, + alertGroupInUsage: { + id: 'course-authoring.group-configurations.content-groups.edit-group.alert-group-in-usage', + defaultMessage: 'This content group is used in one or more units.', + description: 'Alert message displayed when attempting to delete a content group that is currently in use by one or more units.', + }, + deleteRestriction: { + id: 'course-authoring.group-configurations.content-groups.delete-restriction', + defaultMessage: 'Cannot delete when in use by a unit', + description: 'Message indicating that a content group cannot be deleted because it is currently in use by a unit.', + }, + emptyContentGroups: { + id: 'course-authoring.group-configurations.container.empty-content-groups', + defaultMessage: 'In the {outlineComponentLink}, use this group to control access to a component.', + description: 'Message displayed when there are no content groups available, suggesting how to use them within the course outline.', + }, + courseOutline: { + id: 'course-authoring.group-configurations.container.course-outline', + defaultMessage: 'Course outline', + description: 'Label for the course outline link.', + }, + actionEdit: { + id: 'course-authoring.group-configurations.container.action.edit', + defaultMessage: 'Edit', + description: 'Label for the edit action in the container.', + }, + actionDelete: { + id: 'course-authoring.group-configurations.container.action.delete', + defaultMessage: 'Delete', + description: 'Label for the delete action in the container.', + }, + subtitleModalDelete: { + id: 'course-authoring.group-configurations.container.delete-modal.subtitle', + defaultMessage: 'content group', + description: 'Substr for the delete modal indicating the type of entity being deleted.', + }, +}); + +export default messages; diff --git a/src/group-configurations/content-groups-section/utils.js b/src/group-configurations/content-groups-section/utils.js new file mode 100644 index 0000000000..eeecd16067 --- /dev/null +++ b/src/group-configurations/content-groups-section/utils.js @@ -0,0 +1,9 @@ +const isAlreadyExistsGroup = (groupNames, group) => groupNames.some((name) => name === group); + +const initialContentGroupObject = (groupName) => ({ + name: groupName, + version: 1, + usage: [], +}); + +export { isAlreadyExistsGroup, initialContentGroupObject }; diff --git a/src/group-configurations/data/api.js b/src/group-configurations/data/api.js new file mode 100644 index 0000000000..2c5aceb600 --- /dev/null +++ b/src/group-configurations/data/api.js @@ -0,0 +1,115 @@ +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +const API_PATH_PATTERN = 'group_configurations'; +const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL; + +export const getContentStoreApiUrl = (courseId) => `${getStudioBaseUrl()}/api/contentstore/v1/${API_PATH_PATTERN}/${courseId}`; +export const getLegacyApiUrl = (courseId, parentGroupId, groupId) => { + const parentUrlPath = `${getStudioBaseUrl()}/${API_PATH_PATTERN}/${courseId}`; + const parentGroupPath = `${parentGroupId ? `/${parentGroupId}` : ''}`; + const groupPath = `${groupId ? `/${groupId}` : ''}`; + return `${parentUrlPath}${parentGroupPath}${groupPath}`; +}; + +/** + * Get content groups and experimental group configurations for course. + * @param {string} courseId + * @returns {Promise} + */ +export async function getGroupConfigurations(courseId) { + const { data } = await getAuthenticatedHttpClient().get( + getContentStoreApiUrl(courseId), + ); + + return camelCaseObject(data); +} + +/** + * Create new content group for course. + * @param {string} courseId + * @param {object} group + * @returns {Promise} + */ +export async function createContentGroup(courseId, group) { + const { data } = await getAuthenticatedHttpClient().post( + getLegacyApiUrl(courseId, group.id), + group, + ); + + return camelCaseObject(data); +} + +/** + * Edit exists content group in course. + * @param {string} courseId + * @param {object} group + * @returns {Promise} + */ +export async function editContentGroup(courseId, group) { + const { data } = await getAuthenticatedHttpClient().post( + getLegacyApiUrl(courseId, group.id), + group, + ); + + return camelCaseObject(data); +} + +/** + * Delete exists content group from the course. + * @param {string} courseId + * @param {number} parentGroupId + * @param {number} groupId + * @returns {Promise} + */ +export async function deleteContentGroup(courseId, parentGroupId, groupId) { + const { data } = await getAuthenticatedHttpClient().delete( + getLegacyApiUrl(courseId, parentGroupId, groupId), + ); + + return camelCaseObject(data); +} + +/** + * Create a new experiment configuration for the course. + * @param {string} courseId + * @param {object} configuration + * @returns {Promise} + */ +export async function createExperimentConfiguration(courseId, configuration) { + const { data } = await getAuthenticatedHttpClient().post( + getLegacyApiUrl(courseId), + configuration, + ); + + return camelCaseObject(data); +} + +/** + * Edit the experiment configuration for the course. + * @param {string} courseId + * @param {object} configuration + * @returns {Promise} + */ +export async function editExperimentConfiguration(courseId, configuration) { + const { data } = await getAuthenticatedHttpClient().post( + getLegacyApiUrl(courseId, configuration.id), + configuration, + ); + + return camelCaseObject(data); +} + +/** + * Delete existing experimental configuration from the course. + * @param {string} courseId + * @param {number} configurationId + * @returns {Promise} + */ +export async function deleteExperimentConfiguration(courseId, configurationId) { + const { data } = await getAuthenticatedHttpClient().delete( + getLegacyApiUrl(courseId, configurationId), + ); + + return camelCaseObject(data); +} diff --git a/src/group-configurations/data/api.test.js b/src/group-configurations/data/api.test.js new file mode 100644 index 0000000000..fe5ef9fae4 --- /dev/null +++ b/src/group-configurations/data/api.test.js @@ -0,0 +1,149 @@ +import MockAdapter from 'axios-mock-adapter'; +import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { groupConfigurationResponseMock } from '../__mocks__'; +import { initialContentGroupObject } from '../content-groups-section/utils'; +import { initialExperimentConfiguration } from '../experiment-configurations-section/constants'; +import { + createContentGroup, + createExperimentConfiguration, + deleteContentGroup, + editContentGroup, + getContentStoreApiUrl, + getGroupConfigurations, + getLegacyApiUrl, +} from './api'; + +let axiosMock; +const courseId = 'course-v1:org+101+101'; +const contentGroups = groupConfigurationResponseMock.allGroupConfigurations[1]; +const experimentConfigurations = groupConfigurationResponseMock.experimentGroupConfigurations; + +describe('group configurations API calls', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should fetch group configurations', async () => { + const response = { ...groupConfigurationResponseMock }; + axiosMock.onGet(getContentStoreApiUrl(courseId)).reply(200, response); + + const result = await getGroupConfigurations(courseId); + const expected = camelCaseObject(response); + + expect(axiosMock.history.get[0].url).toEqual( + getContentStoreApiUrl(courseId), + ); + expect(result).toEqual(expected); + }); + + it('should create content group', async () => { + const response = { ...groupConfigurationResponseMock }; + const newContentGroupName = 'content-group-test'; + const updatedContentGroups = { + ...contentGroups, + groups: [ + ...contentGroups.groups, + initialContentGroupObject(newContentGroupName), + ], + }; + + response.allGroupConfigurations[1] = updatedContentGroups; + axiosMock + .onPost(getLegacyApiUrl(courseId, contentGroups.id), updatedContentGroups) + .reply(200, response); + + const result = await createContentGroup(courseId, updatedContentGroups); + const expected = camelCaseObject(response); + + expect(axiosMock.history.post[0].url).toEqual( + getLegacyApiUrl(courseId, updatedContentGroups.id), + ); + expect(result).toEqual(expected); + }); + + it('should edit content group', async () => { + const editedName = 'content-group-edited'; + const groupId = contentGroups.groups[0].id; + const response = { ...groupConfigurationResponseMock }; + const editedContentGroups = { + ...contentGroups, + groups: contentGroups.groups.map((group) => (group.id === groupId ? { ...group, name: editedName } : group)), + }; + + response.allGroupConfigurations[1] = editedContentGroups; + axiosMock + .onPost(getLegacyApiUrl(courseId, contentGroups.id), editedContentGroups) + .reply(200, response); + + const result = await editContentGroup(courseId, editedContentGroups); + const expected = camelCaseObject(response); + + expect(axiosMock.history.post[0].url).toEqual( + getLegacyApiUrl(courseId, editedContentGroups.id), + ); + expect(result).toEqual(expected); + }); + + it('should delete content group', async () => { + const parentGroupId = contentGroups.id; + const groupId = contentGroups.groups[0].id; + const response = { ...groupConfigurationResponseMock }; + const updatedContentGroups = { + ...contentGroups, + groups: contentGroups.groups.filter((group) => group.id !== groupId), + }; + + response.allGroupConfigurations[1] = updatedContentGroups; + axiosMock + .onDelete( + getLegacyApiUrl(courseId, parentGroupId, groupId), + updatedContentGroups, + ) + .reply(200, response); + + const result = await deleteContentGroup(courseId, parentGroupId, groupId); + const expected = camelCaseObject(response); + + expect(axiosMock.history.delete[0].url).toEqual( + getLegacyApiUrl(courseId, updatedContentGroups.id, groupId), + ); + expect(result).toEqual(expected); + }); + + it('should create experiment configurations', async () => { + const newConfigurationName = 'experiment-configuration-test'; + const response = { ...groupConfigurationResponseMock }; + const updatedConfigurations = [ + ...experimentConfigurations, + { ...initialExperimentConfiguration, name: newConfigurationName }, + ]; + + response.experimentGroupConfigurations = updatedConfigurations; + axiosMock + .onPost(getLegacyApiUrl(courseId), updatedConfigurations) + .reply(200, response); + + const result = await createExperimentConfiguration( + courseId, + updatedConfigurations, + ); + const expected = camelCaseObject(response); + + expect(axiosMock.history.post[0].url).toEqual(getLegacyApiUrl(courseId)); + expect(result).toEqual(expected); + }); +}); diff --git a/src/group-configurations/data/selectors.js b/src/group-configurations/data/selectors.js new file mode 100644 index 0000000000..7f3f0d230d --- /dev/null +++ b/src/group-configurations/data/selectors.js @@ -0,0 +1,3 @@ +export const getGroupConfigurationsData = (state) => state.groupConfigurations.groupConfigurations; +export const getLoadingStatus = (state) => state.groupConfigurations.loadingStatus; +export const getSavingStatus = (state) => state.groupConfigurations.savingStatus; diff --git a/src/group-configurations/data/slice.js b/src/group-configurations/data/slice.js new file mode 100644 index 0000000000..4530de1943 --- /dev/null +++ b/src/group-configurations/data/slice.js @@ -0,0 +1,78 @@ +/* eslint-disable no-param-reassign */ +import { createSlice } from '@reduxjs/toolkit'; + +import { RequestStatus } from '../../data/constants'; + +const slice = createSlice({ + name: 'groupConfigurations', + initialState: { + savingStatus: '', + loadingStatus: RequestStatus.IN_PROGRESS, + groupConfigurations: {}, + }, + reducers: { + fetchGroupConfigurations: (state, { payload }) => { + state.groupConfigurations = payload.groupConfigurations; + }, + updateGroupConfigurationsSuccess: (state, { payload }) => { + const groupIndex = state.groupConfigurations.allGroupConfigurations.findIndex( + group => payload.data.id === group.id, + ); + + if (groupIndex !== -1) { + state.groupConfigurations.allGroupConfigurations[groupIndex] = payload.data; + } + }, + deleteGroupConfigurationsSuccess: (state, { payload }) => { + const { parentGroupId, groupId } = payload; + const parentGroupIndex = state.groupConfigurations.allGroupConfigurations.findIndex( + group => parentGroupId === group.id, + ); + if (parentGroupIndex !== -1) { + state.groupConfigurations.allGroupConfigurations[parentGroupIndex].groups = state + .groupConfigurations.allGroupConfigurations[parentGroupIndex].groups.filter(group => group.id !== groupId); + } + }, + updateLoadingStatus: (state, { payload }) => { + state.loadingStatus = payload.status; + }, + updateSavingStatuses: (state, { payload }) => { + state.savingStatus = payload.status; + }, + updateExperimentConfigurationSuccess: (state, { payload }) => { + const { configuration } = payload; + const experimentConfigurationState = state.groupConfigurations.experimentGroupConfigurations; + const configurationIdx = experimentConfigurationState.findIndex( + (conf) => configuration.id === conf.id, + ); + + if (configurationIdx !== -1) { + experimentConfigurationState[configurationIdx] = configuration; + } else { + state.groupConfigurations.experimentGroupConfigurations = [ + ...experimentConfigurationState, + configuration, + ]; + } + }, + deleteExperimentConfigurationSuccess: (state, { payload }) => { + const { configurationId } = payload; + const filteredGroups = state.groupConfigurations.experimentGroupConfigurations.filter( + (configuration) => configuration.id !== configurationId, + ); + state.groupConfigurations.experimentGroupConfigurations = filteredGroups; + }, + }, +}); + +export const { + fetchGroupConfigurations, + updateLoadingStatus, + updateSavingStatuses, + updateGroupConfigurationsSuccess, + deleteGroupConfigurationsSuccess, + updateExperimentConfigurationSuccess, + deleteExperimentConfigurationSuccess, +} = slice.actions; + +export const { reducer } = slice; diff --git a/src/group-configurations/data/slice.test.js b/src/group-configurations/data/slice.test.js new file mode 100644 index 0000000000..ebc6ef8780 --- /dev/null +++ b/src/group-configurations/data/slice.test.js @@ -0,0 +1,78 @@ +import { + reducer, + fetchGroupConfigurations, + updateGroupConfigurationsSuccess, + deleteGroupConfigurationsSuccess, + updateExperimentConfigurationSuccess, + deleteExperimentConfigurationSuccess, +} from './slice'; +import { RequestStatus } from '../../data/constants'; + +describe('groupConfigurations slice', () => { + let initialState; + + beforeEach(() => { + initialState = { + savingStatus: '', + loadingStatus: RequestStatus.IN_PROGRESS, + groupConfigurations: { + allGroupConfigurations: [{ id: 1, name: 'Group 1', groups: [{ id: 1, name: 'inner group' }] }], + experimentGroupConfigurations: [], + }, + }; + }); + + it('should update group configurations with fetchGroupConfigurations', () => { + const payload = { + groupConfigurations: { + allGroupConfigurations: [{ id: 2, name: 'Group 2' }], + experimentGroupConfigurations: [], + }, + }; + + const newState = reducer(initialState, fetchGroupConfigurations(payload)); + + expect(newState.groupConfigurations).toEqual(payload.groupConfigurations); + }); + + it('should update an existing group configuration with updateGroupConfigurationsSuccess', () => { + const payload = { data: { id: 1, name: 'Updated Group' } }; + + const newState = reducer(initialState, updateGroupConfigurationsSuccess(payload)); + + expect(newState.groupConfigurations.allGroupConfigurations[0]).toEqual(payload.data); + }); + + it('should delete a group configuration with deleteGroupConfigurationsSuccess', () => { + const payload = { parentGroupId: 1, groupId: 1 }; + + const newState = reducer(initialState, deleteGroupConfigurationsSuccess(payload)); + + expect(newState.groupConfigurations.allGroupConfigurations[0].groups.length).toEqual(0); + }); + + it('should update experiment configuration with updateExperimentConfigurationSuccess', () => { + const payload = { configuration: { id: 1, name: 'Experiment Config' } }; + + const newState = reducer(initialState, updateExperimentConfigurationSuccess(payload)); + + expect(newState.groupConfigurations.experimentGroupConfigurations.length).toEqual(1); + expect(newState.groupConfigurations.experimentGroupConfigurations[0]).toEqual(payload.configuration); + }); + + it('should delete an experiment configuration with deleteExperimentConfigurationSuccess', () => { + const initialStateWithExperiment = { + savingStatus: '', + loadingStatus: RequestStatus.IN_PROGRESS, + groupConfigurations: { + allGroupConfigurations: [], + experimentGroupConfigurations: [{ id: 1, name: 'Experiment Config' }], + }, + }; + const payload = { configurationId: 1 }; + + const newState = reducer(initialStateWithExperiment, deleteExperimentConfigurationSuccess(payload)); + + expect(newState.groupConfigurations.experimentGroupConfigurations.length).toEqual(0); + }); +}); diff --git a/src/group-configurations/data/thunk.js b/src/group-configurations/data/thunk.js new file mode 100644 index 0000000000..019a222ad7 --- /dev/null +++ b/src/group-configurations/data/thunk.js @@ -0,0 +1,148 @@ +import { RequestStatus } from '../../data/constants'; +import { NOTIFICATION_MESSAGES } from '../../constants'; +import { + hideProcessingNotification, + showProcessingNotification, +} from '../../generic/processing-notification/data/slice'; +import { + getGroupConfigurations, + createContentGroup, + editContentGroup, + deleteContentGroup, + createExperimentConfiguration, + editExperimentConfiguration, + deleteExperimentConfiguration, +} from './api'; +import { + fetchGroupConfigurations, + updateLoadingStatus, + updateSavingStatuses, + updateGroupConfigurationsSuccess, + deleteGroupConfigurationsSuccess, + updateExperimentConfigurationSuccess, + deleteExperimentConfigurationSuccess, +} from './slice'; + +export function fetchGroupConfigurationsQuery(courseId) { + return async (dispatch) => { + dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS })); + + try { + const groupConfigurations = await getGroupConfigurations(courseId); + dispatch(fetchGroupConfigurations({ groupConfigurations })); + dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateLoadingStatus({ status: RequestStatus.FAILED })); + } + }; +} + +export function createContentGroupQuery(courseId, group) { + return async (dispatch) => { + dispatch(updateSavingStatuses({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + + try { + const data = await createContentGroup(courseId, group); + dispatch(updateGroupConfigurationsSuccess({ data })); + dispatch(updateSavingStatuses({ status: RequestStatus.SUCCESSFUL })); + return true; + } catch (error) { + dispatch(updateSavingStatuses({ status: RequestStatus.FAILED })); + return false; + } finally { + dispatch(hideProcessingNotification()); + } + }; +} + +export function editContentGroupQuery(courseId, group) { + return async (dispatch) => { + dispatch(updateSavingStatuses({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + + try { + const data = await editContentGroup(courseId, group); + dispatch(updateGroupConfigurationsSuccess({ data })); + dispatch(updateSavingStatuses({ status: RequestStatus.SUCCESSFUL })); + return true; + } catch (error) { + dispatch(updateSavingStatuses({ status: RequestStatus.FAILED })); + return false; + } finally { + dispatch(hideProcessingNotification()); + } + }; +} + +export function deleteContentGroupQuery(courseId, parentGroupId, groupId) { + return async (dispatch) => { + dispatch(updateSavingStatuses({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting)); + + try { + await deleteContentGroup(courseId, parentGroupId, groupId); + dispatch(deleteGroupConfigurationsSuccess({ parentGroupId, groupId })); + dispatch(updateSavingStatuses({ status: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateSavingStatuses({ status: RequestStatus.FAILED })); + } finally { + dispatch(hideProcessingNotification()); + } + }; +} + +export function createExperimentConfigurationQuery(courseId, newConfiguration) { + return async (dispatch) => { + dispatch(updateSavingStatuses({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + + try { + const configuration = await createExperimentConfiguration(courseId, newConfiguration); + dispatch(updateExperimentConfigurationSuccess({ configuration })); + dispatch(updateSavingStatuses({ status: RequestStatus.SUCCESSFUL })); + return true; + } catch (error) { + dispatch(updateSavingStatuses({ status: RequestStatus.FAILED })); + return false; + } finally { + dispatch(hideProcessingNotification()); + } + }; +} + +export function editExperimentConfigurationQuery(courseId, editedConfiguration) { + return async (dispatch) => { + dispatch(updateSavingStatuses({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + + try { + const configuration = await editExperimentConfiguration(courseId, editedConfiguration); + dispatch(updateExperimentConfigurationSuccess({ configuration })); + dispatch(updateSavingStatuses({ status: RequestStatus.SUCCESSFUL })); + return true; + } catch (error) { + dispatch(updateSavingStatuses({ status: RequestStatus.FAILED })); + return false; + } finally { + dispatch(hideProcessingNotification()); + } + }; +} + +export function deleteExperimentConfigurationQuery(courseId, configurationId) { + return async (dispatch) => { + dispatch(updateSavingStatuses({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting)); + + try { + await deleteExperimentConfiguration(courseId, configurationId); + dispatch(deleteExperimentConfigurationSuccess({ configurationId })); + dispatch(updateSavingStatuses({ status: RequestStatus.SUCCESSFUL })); + } catch (error) { + dispatch(updateSavingStatuses({ status: RequestStatus.FAILED })); + } finally { + dispatch(hideProcessingNotification()); + } + }; +} diff --git a/src/group-configurations/data/thunk.test.js b/src/group-configurations/data/thunk.test.js new file mode 100644 index 0000000000..131acec3d1 --- /dev/null +++ b/src/group-configurations/data/thunk.test.js @@ -0,0 +1,95 @@ +import MockAdapter from 'axios-mock-adapter'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { groupConfigurationResponseMock } from '../__mocks__'; +import { getContentStoreApiUrl, getLegacyApiUrl } from './api'; +import * as thunkActions from './thunk'; +import initializeStore from '../../store'; +import { executeThunk } from '../../utils'; + +let axiosMock; +let store; +const courseId = 'course-v1:org+101+101'; + +describe('group configurations thunk', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + const response = { ...groupConfigurationResponseMock }; + axiosMock.onGet(getContentStoreApiUrl(courseId)).reply(200, response); + await executeThunk(thunkActions.fetchGroupConfigurationsQuery(courseId), store.dispatch); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('dispatches correct actions on createContentGroupQuery', async () => { + const mockResponse = { id: 50, name: 'new' }; + axiosMock.onPost(getLegacyApiUrl(courseId)).reply(200, mockResponse); + + await executeThunk(thunkActions.createContentGroupQuery(courseId, {}), store.dispatch); + const updatedGroup = store.getState() + .groupConfigurations.groupConfigurations.allGroupConfigurations + .find(group => group.id === mockResponse.id); + expect(updatedGroup.name).toEqual(mockResponse.name); + }); + it('dispatches correct actions on editContentGroupQuery', async () => { + const mockResponse = { id: 50, name: 'new' }; + axiosMock.onPost(getLegacyApiUrl(courseId)).reply(200, mockResponse); + + await executeThunk(thunkActions.editContentGroupQuery(courseId, {}), store.dispatch); + const updatedGroup = store.getState() + .groupConfigurations.groupConfigurations.allGroupConfigurations + .find(group => group.id === mockResponse.id); + expect(updatedGroup.name).toEqual(mockResponse.name); + }); + it('dispatches correct actions on createExperimentConfigurationQuery', async () => { + const mockResponse = { id: 50, name: 'new' }; + axiosMock.onPost(getLegacyApiUrl(courseId)).reply(200, mockResponse); + + await executeThunk(thunkActions.createExperimentConfigurationQuery(courseId, {}), store.dispatch); + const updatedGroup = store.getState() + .groupConfigurations.groupConfigurations.experimentGroupConfigurations + .find(group => group.id === mockResponse.id); + expect(updatedGroup.name).toEqual(mockResponse.name); + }); + it('dispatches correct actions on editExperimentConfigurationQuery', async () => { + const mockResponse = { id: 50, name: 'new' }; + axiosMock.onPost(getLegacyApiUrl(courseId)).reply(200, mockResponse); + + await executeThunk(thunkActions.editExperimentConfigurationQuery(courseId, {}), store.dispatch); + const updatedGroup = store.getState() + .groupConfigurations.groupConfigurations.experimentGroupConfigurations + .find(group => group.id === mockResponse.id); + expect(updatedGroup.name).toEqual(mockResponse.name); + }); + it('dispatches correct actions on deleteContentGroupQuery', async () => { + const groupToDelete = { id: 6, name: 'deleted' }; + axiosMock.onDelete(getLegacyApiUrl(courseId)).reply(200, {}); + + await executeThunk(thunkActions.deleteContentGroupQuery(courseId, groupToDelete.id), store.dispatch); + const updatedGroup = store.getState() + .groupConfigurations.groupConfigurations.allGroupConfigurations + .find(group => group.id === groupToDelete.id); + expect(updatedGroup).toBeFalsy(); + }); + it('dispatches correct actions on deleteExperimentConfigurationQuery', async () => { + const groupToDelete = { id: 276408623, name: 'deleted' }; + axiosMock.onDelete(getLegacyApiUrl(courseId)).reply(200, {}); + await executeThunk(thunkActions.deleteExperimentConfigurationQuery(courseId, groupToDelete.id), store.dispatch); + const updatedGroup = store.getState() + .groupConfigurations.groupConfigurations.experimentGroupConfigurations + .find(group => group.id === groupToDelete.id); + expect(updatedGroup).toBeFalsy(); + }); +}); diff --git a/src/group-configurations/empty-placeholder/EmptyPlaceholder.scss b/src/group-configurations/empty-placeholder/EmptyPlaceholder.scss new file mode 100644 index 0000000000..1768ecac81 --- /dev/null +++ b/src/group-configurations/empty-placeholder/EmptyPlaceholder.scss @@ -0,0 +1,10 @@ +.group-configurations-empty-placeholder { + @include pgn-box-shadow(1, "down"); + + display: flex; + align-items: center; + justify-content: center; + gap: 1.5rem; + border-radius: .375rem; + padding: 1.5rem; +} diff --git a/src/group-configurations/empty-placeholder/EmptyPlaceholder.test.jsx b/src/group-configurations/empty-placeholder/EmptyPlaceholder.test.jsx new file mode 100644 index 0000000000..5d4ed0b5cb --- /dev/null +++ b/src/group-configurations/empty-placeholder/EmptyPlaceholder.test.jsx @@ -0,0 +1,22 @@ +import { render } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; +import EmptyPlaceholder from '.'; + +const onCreateNewGroup = jest.fn(); + +const renderComponent = () => render( + + + , +); + +describe('', () => { + it('renders EmptyPlaceholder component correctly', () => { + const { getByText, getByRole } = renderComponent(); + + expect(getByText(messages.title.defaultMessage)).toBeInTheDocument(); + expect(getByRole('button', { name: messages.button.defaultMessage })).toBeInTheDocument(); + }); +}); diff --git a/src/group-configurations/empty-placeholder/index.jsx b/src/group-configurations/empty-placeholder/index.jsx new file mode 100644 index 0000000000..80b780d1d5 --- /dev/null +++ b/src/group-configurations/empty-placeholder/index.jsx @@ -0,0 +1,42 @@ +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Add as IconAdd } from '@openedx/paragon/icons'; +import { Button } from '@openedx/paragon'; + +import messages from './messages'; + +const EmptyPlaceholder = ({ onCreateNewGroup, isExperiment }) => { + const { formatMessage } = useIntl(); + const titleMessage = isExperiment + ? messages.experimentalTitle + : messages.title; + const buttonMessage = isExperiment + ? messages.experimentalButton + : messages.button; + + return ( +
+

{formatMessage(titleMessage)}

+ +
+ ); +}; + +EmptyPlaceholder.defaultProps = { + isExperiment: false, +}; + +EmptyPlaceholder.propTypes = { + onCreateNewGroup: PropTypes.func.isRequired, + isExperiment: PropTypes.bool, +}; + +export default EmptyPlaceholder; diff --git a/src/group-configurations/empty-placeholder/messages.js b/src/group-configurations/empty-placeholder/messages.js new file mode 100644 index 0000000000..29fcf3b2cb --- /dev/null +++ b/src/group-configurations/empty-placeholder/messages.js @@ -0,0 +1,26 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + title: { + id: 'course-authoring.group-configurations.empty-placeholder.title', + defaultMessage: 'You have not created any content groups yet.', + description: 'Title displayed when there are no content groups created yet.', + }, + experimentalTitle: { + id: 'course-authoring.group-configurations.experimental-empty-placeholder.title', + defaultMessage: 'You have not created any group configurations yet.', + description: 'Title displayed when there are no experimental group configurations created yet.', + }, + button: { + id: 'course-authoring.group-configurations.empty-placeholder.button', + defaultMessage: 'Add your first content group', + description: 'Label for the button to add the first content group when none exist.', + }, + experimentalButton: { + id: 'course-authoring.group-configurations.experimental-empty-placeholder.button', + defaultMessage: 'Add your first group configuration', + description: 'Label for the button to add the first experimental group configuration when none exist.', + }, +}); + +export default messages; diff --git a/src/group-configurations/enrollment-track-groups-section/EnrollmentTrackGroupsSection.test.jsx b/src/group-configurations/enrollment-track-groups-section/EnrollmentTrackGroupsSection.test.jsx new file mode 100644 index 0000000000..84283ab689 --- /dev/null +++ b/src/group-configurations/enrollment-track-groups-section/EnrollmentTrackGroupsSection.test.jsx @@ -0,0 +1,24 @@ +import { render } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import { enrollmentTrackGroupsMock } from '../__mocks__'; +import EnrollmentTrackGroupsSection from '.'; + +const renderComponent = (props = {}) => render( + + + , +); + +describe('', () => { + it('renders component correctly', () => { + const { getByText, getAllByTestId } = renderComponent(); + expect(getByText(enrollmentTrackGroupsMock.name)).toBeInTheDocument(); + expect(getAllByTestId('content-group-card')).toHaveLength( + enrollmentTrackGroupsMock.groups.length, + ); + }); +}); diff --git a/src/group-configurations/enrollment-track-groups-section/index.jsx b/src/group-configurations/enrollment-track-groups-section/index.jsx new file mode 100644 index 0000000000..3456a926c6 --- /dev/null +++ b/src/group-configurations/enrollment-track-groups-section/index.jsx @@ -0,0 +1,23 @@ +import PropTypes from 'prop-types'; + +import { availableGroupPropTypes } from '../constants'; +import ContentGroupCard from '../content-groups-section/ContentGroupCard'; + +const EnrollmentTrackGroupsSection = ({ availableGroup: { groups, name } }) => ( +
+

{name}

+ {groups.map((group) => ( + + ))} +
+); + +EnrollmentTrackGroupsSection.propTypes = { + availableGroup: PropTypes.shape(availableGroupPropTypes).isRequired, +}; + +export default EnrollmentTrackGroupsSection; diff --git a/src/group-configurations/experiment-configurations-section/ExperimentCard.jsx b/src/group-configurations/experiment-configurations-section/ExperimentCard.jsx new file mode 100644 index 0000000000..60a6d177d2 --- /dev/null +++ b/src/group-configurations/experiment-configurations-section/ExperimentCard.jsx @@ -0,0 +1,221 @@ +import { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { useParams } from 'react-router-dom'; +import { getConfig } from '@edx/frontend-platform'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + ActionRow, + Hyperlink, + Icon, + IconButtonWithTooltip, + useToggle, +} from '@openedx/paragon'; +import { + DeleteOutline as DeleteOutlineIcon, + EditOutline as EditOutlineIcon, +} from '@openedx/paragon/icons'; + +import DeleteModal from '../../generic/delete-modal/DeleteModal'; +import TitleButton from '../common/TitleButton'; +import UsageList from '../common/UsageList'; +import ExperimentCardGroup from './ExperimentCardGroup'; +import ExperimentForm from './ExperimentForm'; +import messages from './messages'; +import { initialExperimentConfiguration } from './constants'; + +const ExperimentCard = ({ + configuration, + experimentConfigurationActions, + isExpandedByDefault, + onCreate, +}) => { + const { formatMessage } = useIntl(); + const { courseId } = useParams(); + const [isExpanded, setIsExpanded] = useState(false); + const [isEditMode, switchOnEditMode, switchOffEditMode] = useToggle(false); + const [isOpenDeleteModal, openDeleteModal, closeDeleteModal] = useToggle(false); + + useEffect(() => { + setIsExpanded(isExpandedByDefault); + }, [isExpandedByDefault]); + + const { + id, groups: groupsControl, description, usage, + } = configuration; + const isUsedInLocation = !!usage?.length; + + const { href: outlineUrl } = new URL( + `/course/${courseId}`, + getConfig().STUDIO_BASE_URL, + ); + + const outlineComponentLink = ( + + {formatMessage(messages.courseOutline)} + + ); + + const guideHowToAdd = ( + + {formatMessage(messages.emptyExperimentGroup, { outlineComponentLink })} + + ); + + // We need to store actual idx as an additional field for getNextGroupName utility. + const configurationGroupsWithIndexField = { + ...configuration, + groups: configuration.groups.map((group, idx) => ({ ...group, idx })), + }; + + const formValues = isEditMode + ? configurationGroupsWithIndexField + : initialExperimentConfiguration; + + const handleDeleteConfiguration = () => { + experimentConfigurationActions.handleDelete(id); + closeDeleteModal(); + }; + + const handleEditConfiguration = (values) => { + experimentConfigurationActions.handleEdit(values, switchOffEditMode); + }; + + return ( + <> + {isEditMode ? ( + + ) : ( +
+
+ setIsExpanded((prevState) => !prevState)} + isExperiment + /> + + + + +
+ {isExpanded && ( +
+ {description} + + {usage?.length ? ( + + ) : ( + guideHowToAdd + )} +
+ )} +
+ )} + + + ); +}; + +ExperimentCard.defaultProps = { + configuration: { + id: undefined, + name: '', + usage: [], + version: undefined, + }, + isExpandedByDefault: false, + onCreate: null, + experimentConfigurationActions: {}, +}; + +ExperimentCard.propTypes = { + configuration: PropTypes.shape({ + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + usage: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + url: PropTypes.string, + validation: PropTypes.shape({ + type: PropTypes.string, + text: PropTypes.string, + }), + }), + ), + version: PropTypes.number.isRequired, + active: PropTypes.bool, + description: PropTypes.string, + groups: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.number, + name: PropTypes.string, + usage: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + url: PropTypes.string, + }), + ), + version: PropTypes.number, + }), + ), + parameters: PropTypes.shape({ + courseId: PropTypes.string, + }), + scheme: PropTypes.string, + }), + isExpandedByDefault: PropTypes.bool, + onCreate: PropTypes.func, + experimentConfigurationActions: PropTypes.shape({ + handleCreate: PropTypes.func, + handleEdit: PropTypes.func, + handleDelete: PropTypes.func, + }), +}; + +export default ExperimentCard; diff --git a/src/group-configurations/experiment-configurations-section/ExperimentCard.test.jsx b/src/group-configurations/experiment-configurations-section/ExperimentCard.test.jsx new file mode 100644 index 0000000000..60c47fc390 --- /dev/null +++ b/src/group-configurations/experiment-configurations-section/ExperimentCard.test.jsx @@ -0,0 +1,123 @@ +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import { experimentGroupConfigurationsMock } from '../__mocks__'; +import commonMessages from '../common/messages'; +import ExperimentCard from './ExperimentCard'; + +const handleCreateMock = jest.fn(); +const handleDeleteMock = jest.fn(); +const handleEditMock = jest.fn(); +const experimentConfigurationActions = { + handleCreate: handleCreateMock, + handleDelete: handleDeleteMock, + handleEdit: handleEditMock, +}; + +const onCreateMock = jest.fn(); +const experimentConfiguration = experimentGroupConfigurationsMock[0]; + +const renderComponent = (props = {}) => render( + + + , +); + +describe('', () => { + it('renders component correctly', () => { + const { getByText, getByTestId } = renderComponent(); + expect(getByText(experimentConfiguration.name)).toBeInTheDocument(); + expect( + getByText( + commonMessages.titleId.defaultMessage.replace( + '{id}', + experimentConfiguration.id, + ), + ), + ).toBeInTheDocument(); + expect(getByTestId('configuration-card-header-edit')).toBeInTheDocument(); + expect(getByTestId('configuration-card-header-delete')).toBeInTheDocument(); + }); + + it('expands/collapses the container experiment configuration on title click', () => { + const { queryByTestId, getByTestId } = renderComponent(); + const cardTitle = getByTestId('configuration-card-header-button'); + userEvent.click(cardTitle); + expect(queryByTestId('configuration-card-content')).toBeInTheDocument(); + + userEvent.click(cardTitle); + expect(queryByTestId('configuration-card-content')).not.toBeInTheDocument(); + }); + + it('renders experiment configuration without access to units', () => { + const experimentConfigurationUpdated = { + ...experimentConfiguration, + usage: [], + }; + const { queryByText, getByTestId } = renderComponent({ + configuration: experimentConfigurationUpdated, + }); + expect( + queryByText(commonMessages.accessTo.defaultMessage), + ).not.toBeInTheDocument(); + + const cardTitle = getByTestId('configuration-card-header-button'); + userEvent.click(cardTitle); + expect( + getByTestId('experiment-configuration-card-usage-empty'), + ).toBeInTheDocument(); + }); + + it('renders usage with validation error message', () => { + const experimentConfigurationUpdated = { + ...experimentConfiguration, + usage: [{ + label: 'Unit1name / Content Experiment', + url: '/container/block-v1:2u+1+1+type@split_test+block@ccfae830ec9b406c835f8ce4520ae395', + validation: { + type: 'warning', + text: 'This content experiment has issues that affect content visibility.', + }, + }], + }; + const { getByText, getByTestId } = renderComponent({ + configuration: experimentConfigurationUpdated, + }); + + const cardTitle = getByTestId('configuration-card-header-button'); + userEvent.click(cardTitle); + + expect( + getByText(experimentConfigurationUpdated.usage[0].validation.text), + ).toBeInTheDocument(); + }); + + it('renders experiment configuration badge that contains groups', () => { + const { queryByTestId } = renderComponent(); + + const usageBlock = queryByTestId('configuration-card-header-button-usage'); + expect(usageBlock).toBeInTheDocument(); + }); + + it("user can't delete experiment configuration that is used in location", () => { + const usageLocation = { + label: 'UnitName 2 / Content Experiment', + url: '/container/block-v1:2u+1+1+type@split_test+block@ccfae830ec9b406c835f8ce4520ae396', + }; + const experimentConfigurationUpdated = { + ...experimentConfiguration, + usage: [usageLocation], + }; + const { getByTestId } = renderComponent({ + configuration: experimentConfigurationUpdated, + }); + const deleteButton = getByTestId('configuration-card-header-delete'); + expect(deleteButton).toBeDisabled(); + }); +}); diff --git a/src/group-configurations/experiment-configurations-section/ExperimentCardGroup.jsx b/src/group-configurations/experiment-configurations-section/ExperimentCardGroup.jsx new file mode 100644 index 0000000000..36cfc1a8e8 --- /dev/null +++ b/src/group-configurations/experiment-configurations-section/ExperimentCardGroup.jsx @@ -0,0 +1,41 @@ +import PropTypes from 'prop-types'; +import { Stack, Truncate } from '@openedx/paragon'; + +import { getGroupPercentage } from './utils'; + +const ExperimentCardGroup = ({ groups }) => { + const percentage = getGroupPercentage(groups.length); + + return ( + + {groups.map((item) => ( +
+ {item.name} + {percentage} +
+ ))} +
+ ); +}; + +ExperimentCardGroup.propTypes = { + groups: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.number, + name: PropTypes.string, + usage: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + url: PropTypes.string, + }), + ), + version: PropTypes.number, + }), + ).isRequired, +}; + +export default ExperimentCardGroup; diff --git a/src/group-configurations/experiment-configurations-section/ExperimentConfigurationsSection.test.jsx b/src/group-configurations/experiment-configurations-section/ExperimentConfigurationsSection.test.jsx new file mode 100644 index 0000000000..580258006f --- /dev/null +++ b/src/group-configurations/experiment-configurations-section/ExperimentConfigurationsSection.test.jsx @@ -0,0 +1,45 @@ +import { render } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import { experimentGroupConfigurationsMock } from '../__mocks__'; +import messages from './messages'; +import ExperimentConfigurationsSection from '.'; + +const handleCreateMock = jest.fn(); +const handleDeleteMock = jest.fn(); +const handleEditMock = jest.fn(); +const experimentConfigurationActions = { + handleCreate: handleCreateMock, + handleDelete: handleDeleteMock, + handleEdit: handleEditMock, +}; + +const renderComponent = (props) => render( + + + , +); + +describe('', () => { + it('renders component correctly', () => { + const { getByText, getByRole, getAllByTestId } = renderComponent(); + expect(getByText(messages.title.defaultMessage)).toBeInTheDocument(); + expect( + getByRole('button', { name: messages.addNewGroup.defaultMessage }), + ).toBeInTheDocument(); + expect(getAllByTestId('configuration-card')).toHaveLength( + experimentGroupConfigurationsMock.length, + ); + }); + + it('renders empty section', () => { + const { getByTestId } = renderComponent({ availableGroups: [] }); + expect( + getByTestId('group-configurations-empty-placeholder'), + ).toBeInTheDocument(); + }); +}); diff --git a/src/group-configurations/experiment-configurations-section/ExperimentForm.jsx b/src/group-configurations/experiment-configurations-section/ExperimentForm.jsx new file mode 100644 index 0000000000..83bd238323 --- /dev/null +++ b/src/group-configurations/experiment-configurations-section/ExperimentForm.jsx @@ -0,0 +1,164 @@ +import PropTypes from 'prop-types'; +import { FieldArray, Formik } from 'formik'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Alert, + ActionRow, + Button, + Form, +} from '@openedx/paragon'; +import { WarningFilled as WarningFilledIcon } from '@openedx/paragon/icons'; + +import PromptIfDirty from '../../generic/prompt-if-dirty/PromptIfDirty'; +import ExperimentFormGroups from './ExperimentFormGroups'; +import messages from './messages'; +import { experimentFormValidationSchema } from './validation'; + +const ExperimentForm = ({ + isEditMode, + initialValues, + isUsedInLocation, + onCreateClick, + onCancelClick, + onEditClick, +}) => { + const { formatMessage } = useIntl(); + const onSubmitForm = isEditMode ? onEditClick : onCreateClick; + + return ( +
+
+

{formatMessage(messages.experimentConfigurationName)}*

+ {isEditMode && ( + + {formatMessage(messages.experimentConfigurationId, { + id: initialValues.id, + })} + + )} +
+ + {({ + values, errors, dirty, handleChange, handleSubmit, + }) => ( + <> + + + + {formatMessage(messages.experimentConfigurationNameFeedback)} + + {errors.name && ( + + {errors.name} + + )} + + + + + {formatMessage(messages.experimentConfigurationDescription)} + + + + {formatMessage( + messages.experimentConfigurationDescriptionFeedback, + )} + + + + ( + arrayHelpers.remove(idx)} + onCreateGroup={(newGroup) => arrayHelpers.push(newGroup)} + /> + )} + /> + + {isUsedInLocation && ( + +

{formatMessage(messages.experimentConfigurationAlert)}

+
+ )} + + + + + + + )} +
+
+ ); +}; + +ExperimentForm.defaultProps = { + isEditMode: false, + isUsedInLocation: false, + onCreateClick: null, + onEditClick: null, +}; + +ExperimentForm.propTypes = { + isEditMode: PropTypes.bool, + initialValues: PropTypes.shape({ + id: PropTypes.number, + name: PropTypes.string, + description: PropTypes.string, + groups: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.number, + groupName: PropTypes.string, + }), + ), + }).isRequired, + isUsedInLocation: PropTypes.bool, + onCreateClick: PropTypes.func, + onCancelClick: PropTypes.func.isRequired, + onEditClick: PropTypes.func, +}; + +export default ExperimentForm; diff --git a/src/group-configurations/experiment-configurations-section/ExperimentForm.test.jsx b/src/group-configurations/experiment-configurations-section/ExperimentForm.test.jsx new file mode 100644 index 0000000000..58ec1e8047 --- /dev/null +++ b/src/group-configurations/experiment-configurations-section/ExperimentForm.test.jsx @@ -0,0 +1,236 @@ +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import userEvent from '@testing-library/user-event'; +import { render, waitFor } from '@testing-library/react'; + +import { experimentGroupConfigurationsMock } from '../__mocks__'; +import messages from './messages'; +import { initialExperimentConfiguration } from './constants'; +import ExperimentForm from './ExperimentForm'; + +const onCreateClickMock = jest.fn(); +const onCancelClickMock = jest.fn(); +const onEditClickMock = jest.fn(); + +const experimentConfiguration = experimentGroupConfigurationsMock[0]; + +const renderComponent = (props = {}) => render( + + + , +); + +describe('', () => { + it('renders component correctly', () => { + const { getByText, getByRole, getByTestId } = renderComponent(); + + expect(getByTestId('experiment-configuration-form')).toBeInTheDocument(); + expect( + getByText(`${messages.experimentConfigurationName.defaultMessage}*`), + ).toBeInTheDocument(); + expect( + getByRole('button', { + name: messages.experimentConfigurationCancel.defaultMessage, + }), + ).toBeInTheDocument(); + expect( + getByRole('button', { + name: messages.experimentConfigurationCreate.defaultMessage, + }), + ).toBeInTheDocument(); + }); + + it('renders component in edit mode', () => { + const { getByText, getByRole } = renderComponent({ + isEditMode: true, + initialValues: experimentConfiguration, + }); + + expect( + getByText( + messages.experimentConfigurationId.defaultMessage.replace( + '{id}', + experimentConfiguration.id, + ), + ), + ).toBeInTheDocument(); + expect( + getByRole('button', { + name: messages.experimentConfigurationSave.defaultMessage, + }), + ).toBeInTheDocument(); + }); + + it('shows alert if group is used in location with edit mode', () => { + const { getByText } = renderComponent({ + isEditMode: true, + initialValues: experimentConfiguration, + isUsedInLocation: true, + }); + expect( + getByText(messages.experimentConfigurationAlert.defaultMessage), + ).toBeInTheDocument(); + }); + + it('calls onCreateClick when the "Create" button is clicked with a valid form', async () => { + const { getByRole, getByPlaceholderText } = renderComponent(); + const nameInput = getByPlaceholderText( + messages.experimentConfigurationNamePlaceholder.defaultMessage, + ); + const descriptionInput = getByPlaceholderText( + messages.experimentConfigurationNamePlaceholder.defaultMessage, + ); + userEvent.type(nameInput, 'New name of the group configuration'); + userEvent.type( + descriptionInput, + 'New description of the group configuration', + ); + const createButton = getByRole('button', { + name: messages.experimentConfigurationCreate.defaultMessage, + }); + expect(createButton).toBeInTheDocument(); + userEvent.click(createButton); + + await waitFor(() => { + expect(onCreateClickMock).toHaveBeenCalledTimes(1); + }); + }); + + it('shows error when the "Create" button is clicked with empty name', async () => { + const { getByRole, getByPlaceholderText, getByText } = renderComponent(); + const nameInput = getByPlaceholderText( + messages.experimentConfigurationNamePlaceholder.defaultMessage, + ); + userEvent.type(nameInput, ''); + + const createButton = getByRole('button', { + name: messages.experimentConfigurationCreate.defaultMessage, + }); + expect(createButton).toBeInTheDocument(); + userEvent.click(createButton); + + await waitFor(() => { + expect( + getByText(messages.experimentConfigurationNameRequired.defaultMessage), + ).toBeInTheDocument(); + }); + }); + + it('shows error when the "Create" button is clicked without groups', async () => { + const experimentConfigurationUpdated = { + ...experimentConfiguration, + name: 'My group configuration name', + groups: [], + }; + const { getByRole, getByText } = renderComponent({ + initialValues: experimentConfigurationUpdated, + }); + const createButton = getByRole('button', { + name: messages.experimentConfigurationCreate.defaultMessage, + }); + expect(createButton).toBeInTheDocument(); + userEvent.click(createButton); + + await waitFor(() => { + expect( + getByText(messages.experimentConfigurationGroupsRequired.defaultMessage), + ).toBeInTheDocument(); + }); + }); + + it('shows error when the "Create" button is clicked with duplicate groups', async () => { + const experimentConfigurationUpdated = { + ...experimentConfiguration, + name: 'My group configuration name', + groups: [ + { + name: 'Group A', + }, + { + name: 'Group A', + }, + ], + }; + const { getByRole, getByText } = renderComponent({ + initialValues: experimentConfigurationUpdated, + }); + const createButton = getByRole('button', { + name: messages.experimentConfigurationCreate.defaultMessage, + }); + expect(createButton).toBeInTheDocument(); + userEvent.click(createButton); + + await waitFor(() => { + expect( + getByText( + messages.experimentConfigurationGroupsNameUnique.defaultMessage, + ), + ).toBeInTheDocument(); + }); + }); + + it('shows error when the "Create" button is clicked with empty name of group', async () => { + const experimentConfigurationUpdated = { + ...experimentConfiguration, + name: 'My group configuration name', + groups: [ + { + name: '', + }, + ], + }; + const { getByRole, getByText } = renderComponent({ + initialValues: experimentConfigurationUpdated, + }); + const createButton = getByRole('button', { + name: messages.experimentConfigurationCreate.defaultMessage, + }); + expect(createButton).toBeInTheDocument(); + userEvent.click(createButton); + + await waitFor(() => { + expect( + getByText( + messages.experimentConfigurationGroupsNameRequired.defaultMessage, + ), + ).toBeInTheDocument(); + }); + }); + + it('calls onEditClick when the "Save" button is clicked with a valid form', async () => { + const { getByRole, getByPlaceholderText } = renderComponent({ + isEditMode: true, + initialValues: experimentConfiguration, + }); + const newConfigurationNameText = 'Updated experiment configuration name'; + const nameInput = getByPlaceholderText( + messages.experimentConfigurationNamePlaceholder.defaultMessage, + ); + userEvent.type(nameInput, newConfigurationNameText); + const saveButton = getByRole('button', { + name: messages.experimentConfigurationSave.defaultMessage, + }); + expect(saveButton).toBeInTheDocument(); + userEvent.click(saveButton); + + await waitFor(() => { + expect(onEditClickMock).toHaveBeenCalledTimes(1); + }); + }); + + it('calls onCancelClick when the "Cancel" button is clicked', async () => { + const { getByRole } = renderComponent(); + const cancelButton = getByRole('button', { + name: messages.experimentConfigurationCancel.defaultMessage, + }); + expect(cancelButton).toBeInTheDocument(); + userEvent.click(cancelButton); + + expect(onCancelClickMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/group-configurations/experiment-configurations-section/ExperimentFormGroups.jsx b/src/group-configurations/experiment-configurations-section/ExperimentFormGroups.jsx new file mode 100644 index 0000000000..c20bfe93f1 --- /dev/null +++ b/src/group-configurations/experiment-configurations-section/ExperimentFormGroups.jsx @@ -0,0 +1,124 @@ +/* eslint-disable react/no-array-index-key */ +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Close as CloseIcon, Add as AddIcon } from '@openedx/paragon/icons'; +import { + Form, Icon, IconButtonWithTooltip, Stack, Button, +} from '@openedx/paragon'; + +import { + getNextGroupName, + getGroupPercentage, + getFormGroupErrors, +} from './utils'; +import messages from './messages'; + +const ExperimentFormGroups = ({ + groups, + errors, + onChange, + onDeleteGroup, + onCreateGroup, +}) => { + const { formatMessage } = useIntl(); + const percentage = getGroupPercentage(groups.length); + const { arrayErrors, stringError } = getFormGroupErrors(errors); + + return ( + + + {formatMessage(messages.experimentConfigurationGroups)}* + + + {formatMessage(messages.experimentConfigurationGroupsFeedback)} + + {stringError && ( + + {stringError} + + )} + + {groups.map((group, idx) => { + const fieldError = arrayErrors?.[idx]?.name; + const isInvalid = !!fieldError; + + return ( + + + +
+ {percentage} +
+ onDeleteGroup(idx)} + /> +
+ {isInvalid && ( + + {fieldError} + + )} +
+ ); + })} +
+ +
+ ); +}; + +ExperimentFormGroups.defaultProps = { + errors: [], +}; + +ExperimentFormGroups.propTypes = { + groups: PropTypes.arrayOf( + PropTypes.shape({ + name: PropTypes.string, + version: PropTypes.number, + usage: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + url: PropTypes.string, + validation: PropTypes.shape({ + type: PropTypes.string, + text: PropTypes.string, + }), + }), + ), + }), + ).isRequired, + onChange: PropTypes.func.isRequired, + onDeleteGroup: PropTypes.func.isRequired, + onCreateGroup: PropTypes.func.isRequired, + errors: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.shape({ name: PropTypes.string })), + PropTypes.string, + ]), +}; + +export default ExperimentFormGroups; diff --git a/src/group-configurations/experiment-configurations-section/constants.js b/src/group-configurations/experiment-configurations-section/constants.js new file mode 100644 index 0000000000..70ed39bc88 --- /dev/null +++ b/src/group-configurations/experiment-configurations-section/constants.js @@ -0,0 +1,18 @@ +export const ALPHABET_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; +export const initialExperimentConfiguration = { + name: '', + description: '', + groups: [ + { + name: 'Group A', version: 1, usage: [], idx: 0, + }, + { + name: 'Group B', version: 1, usage: [], idx: 1, + }, + ], + scheme: 'random', + parameters: {}, + usage: [], + active: true, + version: 1, +}; diff --git a/src/group-configurations/experiment-configurations-section/index.jsx b/src/group-configurations/experiment-configurations-section/index.jsx new file mode 100644 index 0000000000..a5ed9f6365 --- /dev/null +++ b/src/group-configurations/experiment-configurations-section/index.jsx @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import { Button, useToggle } from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Add as AddIcon } from '@openedx/paragon/icons'; + +import { useScrollToHashElement } from '../../hooks'; +import EmptyPlaceholder from '../empty-placeholder'; +import ExperimentForm from './ExperimentForm'; +import ExperimentCard from './ExperimentCard'; +import { initialExperimentConfiguration } from './constants'; +import messages from './messages'; + +const ExperimentConfigurationsSection = ({ + availableGroups, + experimentConfigurationActions, +}) => { + const { formatMessage } = useIntl(); + const [ + isNewConfigurationVisible, + openNewConfiguration, + hideNewConfiguration, + ] = useToggle(false); + + const handleCreateConfiguration = (configuration) => { + experimentConfigurationActions.handleCreate(configuration, hideNewConfiguration); + }; + + const { elementWithHash } = useScrollToHashElement({ isLoading: true }); + + return ( +
+

+ {formatMessage(messages.title)} +

+ {availableGroups.length ? ( + <> + {availableGroups.map((configuration) => ( + + ))} + {!isNewConfigurationVisible && ( + + )} + + ) : ( + !isNewConfigurationVisible && ( + + ) + )} + {isNewConfigurationVisible && ( + + )} +
+ ); +}; + +ExperimentConfigurationsSection.defaultProps = { + availableGroups: [], +}; + +ExperimentConfigurationsSection.propTypes = { + availableGroups: PropTypes.arrayOf( + PropTypes.shape({ + active: PropTypes.bool, + description: PropTypes.string, + groups: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.number, + name: PropTypes.string, + usage: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + url: PropTypes.string, + }), + ), + version: PropTypes.number, + }).isRequired, + ), + id: PropTypes.number, + name: PropTypes.string, + parameters: PropTypes.shape({ + courseId: PropTypes.string, + }), + readOnly: PropTypes.bool, + scheme: PropTypes.string, + version: PropTypes.number, + }).isRequired, + ), + experimentConfigurationActions: PropTypes.shape({ + handleCreate: PropTypes.func, + handleDelete: PropTypes.func, + }).isRequired, +}; + +export default ExperimentConfigurationsSection; diff --git a/src/group-configurations/experiment-configurations-section/messages.js b/src/group-configurations/experiment-configurations-section/messages.js new file mode 100644 index 0000000000..d2370226c7 --- /dev/null +++ b/src/group-configurations/experiment-configurations-section/messages.js @@ -0,0 +1,146 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + title: { + id: 'course-authoring.group-configurations.experiment-configuration.title', + defaultMessage: 'Experiment group configurations', + description: 'Title for the page displaying experiment group configurations.', + }, + addNewGroup: { + id: 'course-authoring.group-configurations.experiment-group.add-new-group', + defaultMessage: 'New group configuration', + description: 'Label for adding a new experiment group configuration.', + }, + experimentConfigurationName: { + id: 'course-authoring.group-configurations.experiment-configuration.container.name', + defaultMessage: 'Group configuration name', + description: 'Label for the input field to enter the name of an experiment group configuration.', + }, + experimentConfigurationId: { + id: 'course-authoring.group-configurations.experiment-configuration.container.id', + defaultMessage: 'Group configuration ID {id}', + description: 'Label displaying the ID of an experiment group configuration.', + }, + experimentConfigurationNameFeedback: { + id: 'course-authoring.group-configurations.experiment-configuration.container.name.feedback', + defaultMessage: 'Name or short description of the configuration.', + description: 'Feedback message for the name/description input field of an experiment group configuration.', + }, + experimentConfigurationNamePlaceholder: { + id: 'course-authoring.group-configurations.experiment-configuration.container.name.placeholder', + defaultMessage: 'This is the name of the group configuration', + description: 'Placeholder text for the name input field of an experiment group configuration.', + }, + experimentConfigurationNameRequired: { + id: 'course-authoring.group-configurations.experiment-configuration.container.name.required', + defaultMessage: 'Group configuration name is required.', + description: 'Error message displayed when the name of the experiment group configuration is required but not provided.', + }, + experimentConfigurationDescription: { + id: 'course-authoring.group-configurations.experiment-configuration.container.description', + defaultMessage: 'Description', + description: 'Label for the description input field of an experiment group configuration.', + }, + experimentConfigurationDescriptionFeedback: { + id: 'course-authoring.group-configurations.experiment-configuration.container.description.feedback', + defaultMessage: 'Optional long description.', + description: 'Feedback message for the description input field of an experiment group configuration.', + }, + experimentConfigurationDescriptionPlaceholder: { + id: 'course-authoring.group-configurations.experiment-configuration.container.description.placeholder', + defaultMessage: 'This is the description of the group configuration', + description: 'Placeholder text for the description input field of an experiment group configuration.', + }, + experimentConfigurationGroups: { + id: 'course-authoring.group-configurations.experiment-configuration.container.groups', + defaultMessage: 'Groups', + description: 'Label for the section displaying groups within an experiment group configuration.', + }, + experimentConfigurationGroupsFeedback: { + id: 'course-authoring.group-configurations.experiment-configuration.container.groups.feedback', + defaultMessage: 'Name of the groups that students will be assigned to, for example, Control, Video, Problems. You must have two or more groups.', + description: 'Feedback message for the groups section of an experiment group configuration.', + }, + experimentConfigurationGroupsNameRequired: { + id: 'course-authoring.group-configurations.experiment-configuration.container.groups.name.required', + defaultMessage: 'All groups must have a name.', + description: 'Error message displayed when the name of a group within an experiment group configuration is required but not provided.', + }, + experimentConfigurationGroupsNameUnique: { + id: 'course-authoring.group-configurations.experiment-configuration.container.groups.name.unique', + defaultMessage: 'All groups must have a unique name.', + description: 'Error message displayed when the names of groups within an experiment group configuration are not unique.', + }, + experimentConfigurationGroupsRequired: { + id: 'course-authoring.group-configurations.experiment-configuration.container.groups.required', + defaultMessage: 'There must be at least one group.', + description: 'Error message displayed when at least one group is required within an experiment group configuration.', + }, + experimentConfigurationGroupsTooltip: { + id: 'course-authoring.group-configurations.experiment-configuration.container.groups.tooltip', + defaultMessage: 'Delete', + description: 'Tooltip message for the delete action within the groups section of an experiment group configuration.', + }, + experimentConfigurationGroupsAdd: { + id: 'course-authoring.group-configurations.experiment-configuration.container.groups.add', + defaultMessage: 'Add another group', + description: 'Label for the button to add another group within the groups section of an experiment group configuration.', + }, + experimentConfigurationDeleteRestriction: { + id: 'course-authoring.group-configurations.experiment-configuration.container.delete.restriction', + defaultMessage: 'Cannot delete when in use by an experiment', + description: 'Error message indicating that an experiment group configuration cannot be deleted because it is currently in use by an experiment.', + }, + experimentConfigurationCancel: { + id: 'course-authoring.group-configurations.experiment-configuration.container.cancel', + defaultMessage: 'Cancel', + description: 'Label for the cancel button within an experiment group configuration.', + }, + experimentConfigurationSave: { + id: 'course-authoring.group-configurations.experiment-configuration.container.save', + defaultMessage: 'Save', + description: 'Label for the save button within an experiment group configuration.', + }, + experimentConfigurationCreate: { + id: 'course-authoring.group-configurations.experiment-configuration.container.create', + defaultMessage: 'Create', + description: 'Label for the create button within an experiment group configuration.', + }, + experimentConfigurationAlert: { + id: 'course-authoring.group-configurations.experiment-configuration.container.alert', + defaultMessage: 'This configuration is currently used in content experiments. If you make changes to the groups, you may need to edit those experiments.', + description: 'Alert message indicating that an experiment group configuration is currently used in content experiments and that changes may require editing those experiments.', + }, + emptyExperimentGroup: { + id: 'course-authoring.group-configurations.experiment-card.empty-experiment-group', + defaultMessage: 'This group configuration is not in use. Start by adding a content experiment to any Unit via the {outlineComponentLink}.', + description: 'Message displayed when an experiment group configuration is not in use and suggests adding a content experiment.', + }, + courseOutline: { + id: 'course-authoring.group-configurations.experiment-card.course-outline', + defaultMessage: 'Course outline', + description: 'Label for the course outline section within an experiment card.', + }, + actionEdit: { + id: 'course-authoring.group-configurations.experiment-card.action.edit', + defaultMessage: 'Edit', + description: 'Label for the edit action within an experiment card.', + }, + actionDelete: { + id: 'course-authoring.group-configurations.experiment-card.action.delete', + defaultMessage: 'Delete', + description: 'Label for the delete action within an experiment card.', + }, + subtitleModalDelete: { + id: 'course-authoring.group-configurations.experiment-card.delete-modal.subtitle', + defaultMessage: 'group configurations', + description: 'Subtitle for the delete modal indicating the type of entity being deleted.', + }, + deleteRestriction: { + id: 'course-authoring.group-configurations.experiment-card.delete-restriction', + defaultMessage: 'Cannot delete when in use by a unit', + description: 'Error message indicating that an experiment card cannot be deleted because it is currently in use by a unit.', + }, +}); + +export default messages; diff --git a/src/group-configurations/experiment-configurations-section/utils.js b/src/group-configurations/experiment-configurations-section/utils.js new file mode 100644 index 0000000000..18d070ecf6 --- /dev/null +++ b/src/group-configurations/experiment-configurations-section/utils.js @@ -0,0 +1,73 @@ +import { isArray } from 'lodash'; + +import { ALPHABET_LETTERS } from './constants'; + +/** + * Generates the next unique group name based on existing group names. + * @param {Array} groups - An array of group objects. + * @param {string} groupFieldName - Optional. The name of the field containing the group name. Default is 'name'. + * @returns {Object} An object containing the next unique group name, along with additional information. + */ +const getNextGroupName = (groups, groupFieldName = 'name') => { + const existingGroupNames = groups.map((group) => group.name); + const lettersCount = ALPHABET_LETTERS.length; + + // Calculate the maximum index of existing groups + const maxIdx = groups.reduce((max, group) => Math.max(max, group.idx), -1); + + // Calculate the next index for the new group + const nextIndex = maxIdx + 1; + + let groupName = ''; + let counter = 0; + + do { + let tempIndex = nextIndex + counter; + groupName = ''; + while (tempIndex >= 0) { + groupName = ALPHABET_LETTERS[tempIndex % lettersCount] + groupName; + tempIndex = Math.floor(tempIndex / lettersCount) - 1; + } + counter++; + } while (existingGroupNames.includes(`Group ${groupName}`)); + + return { + [groupFieldName]: `Group ${groupName}`, version: 1, usage: [], idx: nextIndex, + }; +}; + +/** + * Calculates the percentage of groups values of total groups. + * @param {number} totalGroups - Total number of groups. + * @returns {string} The percentage of groups, each group has the same value. + */ +const getGroupPercentage = (totalGroups) => (totalGroups === 0 ? '0%' : `${Math.floor(100 / totalGroups)}%`); + +/** + * Checks if all group names in the array are unique. + * @param {Array} groups - An array of group objects. + * @returns {boolean} True if all group names are unique, otherwise false. + */ +const allGroupNamesAreUnique = (groups) => { + const names = groups.map((group) => group.name); + return new Set(names).size === names.length; +}; + +/** + * Formats form group errors into an object. Because we need to handle both type errors. + * @param {Array|string} errors - The form group errors. + * @returns {Object} An object containing arrayErrors and stringError properties. + */ +const getFormGroupErrors = (errors) => { + const arrayErrors = isArray(errors) ? errors : []; + const stringError = isArray(errors) ? '' : errors || ''; + + return { arrayErrors, stringError }; +}; + +export { + allGroupNamesAreUnique, + getNextGroupName, + getGroupPercentage, + getFormGroupErrors, +}; diff --git a/src/group-configurations/experiment-configurations-section/utils.test.js b/src/group-configurations/experiment-configurations-section/utils.test.js new file mode 100644 index 0000000000..4e0e5f9272 --- /dev/null +++ b/src/group-configurations/experiment-configurations-section/utils.test.js @@ -0,0 +1,174 @@ +import { + allGroupNamesAreUnique, + getNextGroupName, + getGroupPercentage, +} from './utils'; + +describe('utils module', () => { + describe('getNextGroupName', () => { + it('return correct next group name test-case-1', () => { + const groups = [ + { + name: 'Group A', idx: 0, + }, + { + name: 'Group B', idx: 1, + }, + ]; + const nextGroup = getNextGroupName(groups); + expect(nextGroup.name).toBe('Group C'); + expect(nextGroup.idx).toBe(2); + }); + + it('return correct next group name test-case-2', () => { + const groups = []; + const nextGroup = getNextGroupName(groups); + expect(nextGroup.name).toBe('Group A'); + expect(nextGroup.idx).toBe(0); + }); + + it('return correct next group name test-case-3', () => { + const groups = [ + { + name: 'Some group', idx: 0, + }, + { + name: 'Group B', idx: 1, + }, + ]; + const nextGroup = getNextGroupName(groups); + expect(nextGroup.name).toBe('Group C'); + expect(nextGroup.idx).toBe(2); + }); + + it('return correct next group name test-case-4', () => { + const groups = [ + { + name: 'Group A', idx: 0, + }, + { + name: 'Group A', idx: 1, + }, + ]; + const nextGroup = getNextGroupName(groups); + expect(nextGroup.name).toBe('Group C'); + expect(nextGroup.idx).toBe(2); + }); + + it('return correct next group name test-case-5', () => { + const groups = [ + { + name: 'Group A', idx: 0, + }, + { + name: 'Group C', idx: 1, + }, + { + name: 'Group B', idx: 2, + }, + ]; + const nextGroup = getNextGroupName(groups); + expect(nextGroup.name).toBe('Group D'); + expect(nextGroup.idx).toBe(3); + }); + + it('return correct next group name test-case-6', () => { + const groups = [ + { + name: '', idx: 0, + }, + { + name: '', idx: 1, + }, + ]; + const nextGroup = getNextGroupName(groups); + expect(nextGroup.name).toBe('Group C'); + expect(nextGroup.idx).toBe(2); + }); + + it('return correct next group name test-case-7', () => { + const groups = [ + { + name: 'Group A', idx: 0, + }, + { + name: 'Group C', idx: 1, + }, + ]; + const nextGroup = getNextGroupName(groups); + expect(nextGroup.name).toBe('Group D'); + expect(nextGroup.idx).toBe(2); + }); + + it('return correct next group name test-case-8', () => { + const groups = [ + { + name: 'Group D', idx: 0, + }, + ]; + const nextGroup = getNextGroupName(groups); + expect(nextGroup.name).toBe('Group B'); + expect(nextGroup.idx).toBe(1); + }); + + it('return correct next group name test-case-9', () => { + const groups = [ + { + name: 'Group E', idx: 4, + }, + ]; + const nextGroup = getNextGroupName(groups); + expect(nextGroup.name).toBe('Group F'); + }); + + it('return correct next group name test-case-10', () => { + const groups = [ + { + name: 'Group E', idx: 0, + }, + ]; + const nextGroup = getNextGroupName(groups); + expect(nextGroup.name).toBe('Group B'); + }); + + it('return correct next group name test-case-11', () => { + const simulatedGroupWithAlphabetLength = Array.from( + { length: 26 }, + (_, idx) => ({ name: 'Test name', idx }), + ); + const nextGroup = getNextGroupName(simulatedGroupWithAlphabetLength); + expect(nextGroup.name).toBe('Group AA'); + }); + + it('return correct next group name test-case-12', () => { + const simulatedGroupWithAlphabetLength = Array.from( + { length: 702 }, + (_, idx) => ({ name: 'Test name', idx }), + ); + const nextGroup = getNextGroupName(simulatedGroupWithAlphabetLength); + expect(nextGroup.name).toBe('Group AAA'); + }); + }); + + describe('getGroupPercentage', () => { + it('calculates group percentage correctly', () => { + expect(getGroupPercentage(1)).toBe('100%'); + expect(getGroupPercentage(7)).toBe('14%'); + expect(getGroupPercentage(10)).toBe('10%'); + expect(getGroupPercentage(26)).toBe('3%'); + expect(getGroupPercentage(100)).toBe('1%'); + }); + }); + + describe('allGroupNamesAreUnique', () => { + it('returns true if all group names are unique', () => { + const groups = [{ name: 'A' }, { name: 'B' }, { name: 'C' }]; + expect(allGroupNamesAreUnique(groups)).toBe(true); + }); + + it('returns false if any group names are not unique', () => { + const groups = [{ name: 'A' }, { name: 'B' }, { name: 'A' }]; + expect(allGroupNamesAreUnique(groups)).toBe(false); + }); + }); +}); diff --git a/src/group-configurations/experiment-configurations-section/validation.js b/src/group-configurations/experiment-configurations-section/validation.js new file mode 100644 index 0000000000..e1d02df0d4 --- /dev/null +++ b/src/group-configurations/experiment-configurations-section/validation.js @@ -0,0 +1,45 @@ +import * as Yup from 'yup'; + +import messages from './messages'; +import { allGroupNamesAreUnique } from './utils'; + +// eslint-disable-next-line import/prefer-default-export +export const experimentFormValidationSchema = (formatMessage) => Yup.object().shape({ + id: Yup.number(), + name: Yup.string() + .trim() + .required(formatMessage(messages.experimentConfigurationNameRequired)), + description: Yup.string(), + groups: Yup.array() + .of( + Yup.object().shape({ + id: Yup.number(), + name: Yup.string() + .trim() + .required( + formatMessage(messages.experimentConfigurationGroupsNameRequired), + ), + version: Yup.number(), + usage: Yup.array().nullable(true), + }), + ) + .required() + .min(1, formatMessage(messages.experimentConfigurationGroupsRequired)) + .test( + 'unique-group-name-restriction', + formatMessage(messages.experimentConfigurationGroupsNameUnique), + (values) => allGroupNamesAreUnique(values), + ), + scheme: Yup.string(), + version: Yup.number(), + parameters: Yup.object(), + usage: Yup.array() + .of( + Yup.object().shape({ + label: Yup.string(), + url: Yup.string(), + }), + ) + .nullable(true), + active: Yup.bool(), +}); diff --git a/src/group-configurations/group-configuration-sidebar/GroupConfigurationSidebar.test.jsx b/src/group-configurations/group-configuration-sidebar/GroupConfigurationSidebar.test.jsx new file mode 100644 index 0000000000..eb5cb99886 --- /dev/null +++ b/src/group-configurations/group-configuration-sidebar/GroupConfigurationSidebar.test.jsx @@ -0,0 +1,104 @@ +import { render } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { AppProvider } from '@edx/frontend-platform/react'; + +import initializeStore from '../../store'; +import GroupConfigurationSidebar from '.'; +import messages from './messages'; + +let store; +const courseId = 'course-123'; +const enrollmentTrackTitle = messages.about_3_title.defaultMessage; +const contentGroupTitle = messages.aboutTitle.defaultMessage; +const experimentGroupTitle = messages.about_2_title.defaultMessage; + +jest.mock('@edx/frontend-platform/i18n', () => ({ + ...jest.requireActual('@edx/frontend-platform/i18n'), + useIntl: () => ({ + formatMessage: (message) => message.defaultMessage, + }), +})); + +const renderComponent = (props) => render( + + + + , + , +); + +describe('GroupConfigurationSidebar', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + }); + + it('renders all groups when all props are true', async () => { + const { findAllByRole } = renderComponent({ + shouldShowExperimentGroups: true, + shouldShowContentGroup: true, + shouldShowEnrollmentTrackGroup: true, + }); + const titles = await findAllByRole('heading', { level: 4 }); + + expect(titles[0]).toHaveTextContent(enrollmentTrackTitle); + expect(titles[1]).toHaveTextContent(contentGroupTitle); + expect(titles[2]).toHaveTextContent(experimentGroupTitle); + }); + + it('renders no groups when all props are false', async () => { + const { queryByText } = renderComponent({ + shouldShowExperimentGroups: false, + shouldShowContentGroup: false, + shouldShowEnrollmentTrackGroup: false, + }); + + expect(queryByText(enrollmentTrackTitle)).not.toBeInTheDocument(); + expect(queryByText(contentGroupTitle)).not.toBeInTheDocument(); + expect(queryByText(experimentGroupTitle)).not.toBeInTheDocument(); + }); + + it('renders only content group when shouldShowContentGroup is true', async () => { + const { queryByText, getByText } = renderComponent({ + shouldShowExperimentGroups: false, + shouldShowContentGroup: true, + shouldShowEnrollmentTrackGroup: false, + }); + + expect(queryByText(enrollmentTrackTitle)).not.toBeInTheDocument(); + expect(getByText(contentGroupTitle)).toBeInTheDocument(); + expect(queryByText(experimentGroupTitle)).not.toBeInTheDocument(); + }); + + it('renders only experiment group when shouldShowExperimentGroups is true', async () => { + const { queryByText, getByText } = renderComponent({ + shouldShowExperimentGroups: true, + shouldShowContentGroup: false, + shouldShowEnrollmentTrackGroup: false, + }); + + expect(queryByText(enrollmentTrackTitle)).not.toBeInTheDocument(); + expect(queryByText(contentGroupTitle)).not.toBeInTheDocument(); + expect(getByText(experimentGroupTitle)).toBeInTheDocument(); + }); + + it('renders only enrollment track group when shouldShowEnrollmentTrackGroup is true', async () => { + const { queryByText, getByText } = renderComponent({ + shouldShowExperimentGroups: false, + shouldShowContentGroup: false, + shouldShowEnrollmentTrackGroup: true, + }); + + expect(getByText(enrollmentTrackTitle)).toBeInTheDocument(); + expect(queryByText(contentGroupTitle)).not.toBeInTheDocument(); + expect(queryByText(experimentGroupTitle)).not.toBeInTheDocument(); + }); +}); diff --git a/src/group-configurations/group-configuration-sidebar/index.jsx b/src/group-configurations/group-configuration-sidebar/index.jsx new file mode 100644 index 0000000000..99dbf6bc4b --- /dev/null +++ b/src/group-configurations/group-configuration-sidebar/index.jsx @@ -0,0 +1,59 @@ +import { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Hyperlink } from '@openedx/paragon'; + +import { HelpSidebar } from '../../generic/help-sidebar'; +import { useHelpUrls } from '../../help-urls/hooks'; +import { getSidebarData } from './utils'; +import messages from './messages'; + +const GroupConfigurationSidebar = ({ + courseId, shouldShowExperimentGroups, shouldShowContentGroup, shouldShowEnrollmentTrackGroup, +}) => { + const intl = useIntl(); + const urls = useHelpUrls(['groupConfigurations', 'enrollmentTracks', 'contentGroups']); + const sidebarData = getSidebarData({ + messages, intl, shouldShowExperimentGroups, shouldShowContentGroup, shouldShowEnrollmentTrackGroup, + }); + + return ( + + {sidebarData + .map(({ title, paragraphs, urlKey }, idx) => ( + +

+ {title} +

+ {paragraphs.map((text) => ( +

+ {text} +

+ ))} + + {intl.formatMessage(messages.learnMoreBtn)} + + {idx !== sidebarData.length - 1 &&
} +
+ ))} +
+ ); +}; + +GroupConfigurationSidebar.propTypes = { + courseId: PropTypes.string.isRequired, + shouldShowContentGroup: PropTypes.bool.isRequired, + shouldShowExperimentGroups: PropTypes.bool.isRequired, + shouldShowEnrollmentTrackGroup: PropTypes.bool.isRequired, +}; + +export default GroupConfigurationSidebar; diff --git a/src/group-configurations/group-configuration-sidebar/messages.js b/src/group-configurations/group-configuration-sidebar/messages.js new file mode 100644 index 0000000000..3404e8c9cb --- /dev/null +++ b/src/group-configurations/group-configuration-sidebar/messages.js @@ -0,0 +1,81 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + aboutTitle: { + id: 'course-authoring.group-configurations.sidebar.about.title', + defaultMessage: 'Content groups', + description: 'Title for the content groups section in the sidebar.', + }, + aboutDescription_1: { + id: 'course-authoring.group-configurations.sidebar.about.description-1', + defaultMessage: 'If you have cohorts enabled in your course, you can use content groups to create cohort-specific courseware. In other words, you can customize the content that particular cohorts see in your course.', + description: 'First description for the content groups section in the sidebar.', + }, + aboutDescription_2: { + id: 'course-authoring.group-configurations.sidebar.about.description-2', + defaultMessage: 'Each content group that you create can be associated with one or more cohorts. In addition to making course content available to all learners, you can restrict access to some content to learners in specific content groups. Only learners in the cohorts that are associated with the specified content groups see the additional content.', + description: 'Second description for the content groups section in the sidebar.', + }, + aboutDescription_3: { + id: 'course-authoring.group-configurations.sidebar.about.description-3', + defaultMessage: 'Click {strongText} to add a new content group. To edit the name of a content group, hover over its box and click {strongText2}. You can delete a content group only if it is not in use by a unit. To delete a content group, hover over its box and click the delete icon.', + description: 'Third description for the content groups section in the sidebar. Mentions how to add, edit, and delete content groups.', + }, + aboutDescription_3_strong: { + id: 'course-authoring.group-configurations.sidebar.about.description-3.strong', + defaultMessage: 'New content group', + description: 'Strong text (button label) used in the third description for adding a new content group.', + }, + about_2_title: { + id: 'course-authoring.group-configurations.sidebar.about-2.title', + defaultMessage: 'Experiment group configurations', + description: 'Title for the experiment group configurations section in the sidebar.', + }, + about_2_description_1: { + id: 'course-authoring.group-configurations.sidebar.about-2.description-1', + defaultMessage: 'Use experiment group configurations if you are conducting content experiments, also known as A/B testing, in your course. Experiment group configurations define how many groups of learners are in a content experiment. When you create a content experiment for a course, you select the group configuration to use.', + description: 'First description for the experiment group configurations section in the sidebar.', + }, + about_2_description_2: { + id: 'course-authoring.group-configurations.sidebar.about-2.description-2', + defaultMessage: 'Click {strongText} to add a new configuration. To edit a configuration, hover over its box and click {strongText2}. You can delete a group configuration only if it is not in use in an experiment. To delete a configuration, hover over its box and click the delete icon.', + description: 'Second description for the experiment group configurations section in the sidebar. Mentions how to add, edit, and delete group configurations.', + }, + about_2_description_2_strong: { + id: 'course-authoring.group-configurations.sidebar.about-2.description-2.strong', + defaultMessage: 'New group configuration', + description: 'Strong text (button label) used in the second description for adding a new group configuration.', + }, + about_3_title: { + id: 'course-authoring.group-configurations.sidebar.about-3.title', + defaultMessage: 'Enrollment track groups', + description: 'Title for the enrollment track groups section in the sidebar.', + }, + about_3_description_1: { + id: 'course-authoring.group-configurations.sidebar.about-3.description-1', + defaultMessage: 'Enrollment track groups allow you to offer different course content to learners in each enrollment track. Learners enrolled in each enrollment track in your course are automatically included in the corresponding enrollment track group.', + description: 'First description for the enrollment track groups section in the sidebar.', + }, + about_3_description_2: { + id: 'course-authoring.group-configurations.sidebar.about-3.description-2', + defaultMessage: 'On unit pages in the course outline, you can restrict access to components to learners based on their enrollment track.', + description: 'Second description for the enrollment track groups section in the sidebar.', + }, + about_3_description_3: { + id: 'course-authoring.group-configurations.sidebar.about-3.description-3', + defaultMessage: 'You cannot edit enrollment track groups, but you can expand each group to view details of the course content that is designated for learners in the group.', + description: 'Third description for the enrollment track groups section in the sidebar. Mentions the limitations and options for managing enrollment track groups.', + }, + aboutDescription_strong_edit: { + id: 'course-authoring.group-configurations.sidebar.about.description.strong-edit', + defaultMessage: 'edit', + description: 'Strong text used to indicate the edit action.', + }, + learnMoreBtn: { + id: 'course-authoring.group-configurations.sidebar.learnmore.button', + defaultMessage: 'Learn more', + description: 'Label for the "Learn more" button in the sidebar.', + }, +}); + +export default messages; diff --git a/src/group-configurations/group-configuration-sidebar/utils.jsx b/src/group-configurations/group-configuration-sidebar/utils.jsx new file mode 100644 index 0000000000..d039c8f440 --- /dev/null +++ b/src/group-configurations/group-configuration-sidebar/utils.jsx @@ -0,0 +1,57 @@ +/** + * Compiles the sidebar data for the course authoring sidebar. + * + * @param {Object} messages - The localized messages. + * @param {Object} intl - The intl object for formatting messages. + * @param {boolean} shouldShowExperimentGroups - Flag to include experiment group configuration data. + * @param {boolean} shouldShowContentGroup - Flag to include content group data. + * @param {boolean} shouldShowEnrollmentTrackGroup - Flag to include enrollment track group data. + * @returns {Object[]} The array of sidebar data groups. + */ +const getSidebarData = ({ + messages, intl, shouldShowExperimentGroups, shouldShowContentGroup, shouldShowEnrollmentTrackGroup, +}) => { + const groups = []; + + if (shouldShowEnrollmentTrackGroup) { + groups.push({ + urlKey: 'enrollmentTracks', + title: intl.formatMessage(messages.about_3_title), + paragraphs: [ + intl.formatMessage(messages.about_3_description_1), + intl.formatMessage(messages.about_3_description_2), + intl.formatMessage(messages.about_3_description_3), + ], + }); + } + if (shouldShowContentGroup) { + groups.push({ + urlKey: 'contentGroups', + title: intl.formatMessage(messages.aboutTitle), + paragraphs: [ + intl.formatMessage(messages.aboutDescription_1), + intl.formatMessage(messages.aboutDescription_2), + intl.formatMessage(messages.aboutDescription_3, { + strongText: {intl.formatMessage(messages.aboutDescription_3_strong)}, + strongText2: {intl.formatMessage(messages.aboutDescription_strong_edit)}, + }), + ], + }); + } + if (shouldShowExperimentGroups) { + groups.push({ + urlKey: 'groupConfigurations', + title: intl.formatMessage(messages.about_2_title), + paragraphs: [ + intl.formatMessage(messages.about_2_description_1), + intl.formatMessage(messages.about_2_description_2, { + strongText: {intl.formatMessage(messages.about_2_description_2_strong)}, + strongText2: {intl.formatMessage(messages.aboutDescription_strong_edit)}, + }), + ], + }); + } + return groups; +}; +// eslint-disable-next-line import/prefer-default-export +export { getSidebarData }; diff --git a/src/group-configurations/hooks.jsx b/src/group-configurations/hooks.jsx new file mode 100644 index 0000000000..9766776c76 --- /dev/null +++ b/src/group-configurations/hooks.jsx @@ -0,0 +1,97 @@ +import { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { RequestStatus } from '../data/constants'; +import { getProcessingNotification } from '../generic/processing-notification/data/selectors'; +import { + getGroupConfigurationsData, + getLoadingStatus, + getSavingStatus, +} from './data/selectors'; +import { updateSavingStatuses } from './data/slice'; +import { + createContentGroupQuery, + createExperimentConfigurationQuery, + deleteContentGroupQuery, + deleteExperimentConfigurationQuery, + editContentGroupQuery, + editExperimentConfigurationQuery, + fetchGroupConfigurationsQuery, +} from './data/thunk'; + +const useGroupConfigurations = (courseId) => { + const dispatch = useDispatch(); + const groupConfigurations = useSelector(getGroupConfigurationsData); + const loadingStatus = useSelector(getLoadingStatus); + const savingStatus = useSelector(getSavingStatus); + const { + isShow: isShowProcessingNotification, + title: processingNotificationTitle, + } = useSelector(getProcessingNotification); + + const handleInternetConnectionFailed = () => { + dispatch(updateSavingStatuses({ status: RequestStatus.FAILED })); + }; + + const contentGroupActions = { + handleCreate: (group, callbackToClose) => { + dispatch(createContentGroupQuery(courseId, group)).then((result) => { + if (result) { + callbackToClose(); + } + }); + }, + handleEdit: (group, callbackToClose) => { + dispatch(editContentGroupQuery(courseId, group)).then((result) => { + if (result) { + callbackToClose(); + } + }); + }, + handleDelete: (parentGroupId, groupId) => { + dispatch(deleteContentGroupQuery(courseId, parentGroupId, groupId)); + }, + }; + + const experimentConfigurationActions = { + handleCreate: (configuration, callbackToClose) => { + dispatch( + createExperimentConfigurationQuery(courseId, configuration), + ).then((result) => { + if (result) { + callbackToClose(); + } + }); + }, + handleEdit: (configuration, callbackToClose) => { + dispatch(editExperimentConfigurationQuery(courseId, configuration)).then( + (result) => { + if (result) { + callbackToClose(); + } + }, + ); + }, + handleDelete: (configurationId) => { + dispatch(deleteExperimentConfigurationQuery(courseId, configurationId)); + }, + }; + + useEffect(() => { + dispatch(fetchGroupConfigurationsQuery(courseId)); + }, [courseId]); + + return { + isLoading: loadingStatus === RequestStatus.IN_PROGRESS, + savingStatus, + contentGroupActions, + experimentConfigurationActions, + groupConfigurations, + isShowProcessingNotification, + processingNotificationTitle, + handleInternetConnectionFailed, + }; +}; + +// eslint-disable-next-line import/prefer-default-export +export { useGroupConfigurations }; diff --git a/src/group-configurations/hooks.test.jsx b/src/group-configurations/hooks.test.jsx new file mode 100644 index 0000000000..87ed09f6c8 --- /dev/null +++ b/src/group-configurations/hooks.test.jsx @@ -0,0 +1,109 @@ +import MockAdapter from 'axios-mock-adapter'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { renderHook } from '@testing-library/react-hooks'; +import { Provider, useDispatch } from 'react-redux'; + +import { RequestStatus } from '../data/constants'; +import initializeStore from '../store'; +import { getContentStoreApiUrl } from './data/api'; +import { + createContentGroupQuery, + createExperimentConfigurationQuery, + deleteContentGroupQuery, + deleteExperimentConfigurationQuery, + editContentGroupQuery, + editExperimentConfigurationQuery, +} from './data/thunk'; +import { groupConfigurationResponseMock } from './__mocks__'; +import { useGroupConfigurations } from './hooks'; +import { updateSavingStatuses } from './data/slice'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: jest.fn(), +})); + +jest.mock('./data/thunk', () => ({ + ...jest.requireActual('./data/thunk'), + createContentGroupQuery: jest.fn().mockResolvedValue(true), + createExperimentConfigurationQuery: jest.fn().mockResolvedValue(true), + deleteContentGroupQuery: jest.fn().mockResolvedValue(true), + deleteExperimentConfigurationQuery: jest.fn().mockResolvedValue(true), + editContentGroupQuery: jest.fn().mockResolvedValue(true), + editExperimentConfigurationQuery: jest.fn().mockResolvedValue(true), + getContentStoreApiUrlQuery: jest.fn().mockResolvedValue(true), +})); + +let axiosMock; +let store; +const courseId = 'course-v1:org+101+101'; +const mockObject = {}; +const mockFunc = jest.fn(); +let dispatch; + +const wrapper = ({ children }) => ( + + + {children} + + +); + +describe('useGroupConfigurations', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock + .onGet(getContentStoreApiUrl(courseId)) + .reply(200, groupConfigurationResponseMock); + dispatch = jest.fn().mockImplementation(() => Promise.resolve(true)); + useDispatch.mockReturnValue(dispatch); + }); + + it('successfully dispatches handleInternetConnectionFailed', async () => { + const { result } = renderHook(() => useGroupConfigurations(courseId), { wrapper }); + result.current.handleInternetConnectionFailed(); + expect(dispatch).toHaveBeenCalledWith(updateSavingStatuses({ status: RequestStatus.FAILED })); + }); + it('successfully dispatches handleCreate for group configuration', async () => { + const { result } = renderHook(() => useGroupConfigurations(courseId), { wrapper }); + result.current.contentGroupActions.handleCreate(mockObject, mockFunc); + expect(dispatch).toHaveBeenCalledWith(createContentGroupQuery(courseId, mockObject)); + }); + it('successfully dispatches handleEdit for group configuration', async () => { + const { result } = renderHook(() => useGroupConfigurations(courseId), { wrapper }); + result.current.contentGroupActions.handleEdit(mockObject, mockFunc); + expect(dispatch).toHaveBeenCalledWith(editContentGroupQuery(courseId, mockObject)); + }); + it('successfully dispatches handleDelete for group configuration', async () => { + const { result } = renderHook(() => useGroupConfigurations(courseId), { wrapper }); + result.current.contentGroupActions.handleDelete(1, 1); + expect(dispatch).toHaveBeenCalledWith(deleteContentGroupQuery(courseId, 1, 1)); + }); + it('successfully dispatches handleCreate for experiment group', async () => { + const { result } = renderHook(() => useGroupConfigurations(courseId), { wrapper }); + result.current.experimentConfigurationActions.handleCreate(mockObject, mockFunc); + expect(dispatch).toHaveBeenCalledWith(createExperimentConfigurationQuery(courseId, mockObject)); + }); + it('successfully dispatches handleEdit for experiment group', async () => { + const { result } = renderHook(() => useGroupConfigurations(courseId), { wrapper }); + result.current.experimentConfigurationActions.handleEdit(mockObject, mockFunc); + expect(dispatch).toHaveBeenCalledWith(editExperimentConfigurationQuery(courseId, mockObject)); + }); + it('successfully dispatches handleDelete for experiment group', async () => { + const { result } = renderHook(() => useGroupConfigurations(courseId), { wrapper }); + result.current.experimentConfigurationActions.handleDelete(mockObject, 1); + expect(dispatch).toHaveBeenCalledWith(deleteExperimentConfigurationQuery(courseId, 1)); + }); +}); diff --git a/src/group-configurations/index.jsx b/src/group-configurations/index.jsx new file mode 100644 index 0000000000..9c59251312 --- /dev/null +++ b/src/group-configurations/index.jsx @@ -0,0 +1,123 @@ +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Container, Layout, Stack, Row, +} from '@openedx/paragon'; + +import { RequestStatus } from '../data/constants'; +import { LoadingSpinner } from '../generic/Loading'; +import { useModel } from '../generic/model-store'; +import SubHeader from '../generic/sub-header/SubHeader'; +import getPageHeadTitle from '../generic/utils'; +import ProcessingNotification from '../generic/processing-notification'; +import InternetConnectionAlert from '../generic/internet-connection-alert'; +import messages from './messages'; +import ContentGroupsSection from './content-groups-section'; +import ExperimentConfigurationsSection from './experiment-configurations-section'; +import EnrollmentTrackGroupsSection from './enrollment-track-groups-section'; +import GroupConfigurationSidebar from './group-configuration-sidebar'; +import { useGroupConfigurations } from './hooks'; + +const GroupConfigurations = ({ courseId }) => { + const { formatMessage } = useIntl(); + const courseDetails = useModel('courseDetails', courseId); + const { + isLoading, + savingStatus, + contentGroupActions, + experimentConfigurationActions, + processingNotificationTitle, + isShowProcessingNotification, + groupConfigurations: { + allGroupConfigurations, + shouldShowEnrollmentTrack, + shouldShowExperimentGroups, + experimentGroupConfigurations, + }, + handleInternetConnectionFailed, + } = useGroupConfigurations(courseId); + + document.title = getPageHeadTitle( + courseDetails?.name, + formatMessage(messages.headingTitle), + ); + + if (isLoading) { + return ( + + + + ); + } + + const enrollmentTrackGroup = shouldShowEnrollmentTrack + ? allGroupConfigurations[0] + : null; + const contentGroup = allGroupConfigurations?.[shouldShowEnrollmentTrack ? 1 : 0]; + + return ( + <> + +
+ + + + + {!!enrollmentTrackGroup && ( + + )} + {!!contentGroup && ( + + )} + {shouldShowExperimentGroups && ( + + )} + + + + + + + +
+ + +
+ + ); +}; + +GroupConfigurations.propTypes = { + courseId: PropTypes.string.isRequired, +}; + +export default GroupConfigurations; diff --git a/src/group-configurations/messages.js b/src/group-configurations/messages.js new file mode 100644 index 0000000000..01e0eacefd --- /dev/null +++ b/src/group-configurations/messages.js @@ -0,0 +1,31 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + headingTitle: { + id: 'course-authoring.group-configurations.heading-title', + defaultMessage: 'Group configurations', + description: 'Title for the heading of the group configurations section.', + }, + headingSubtitle: { + id: 'course-authoring.group-configurations.heading-sub-title', + defaultMessage: 'Settings', + description: 'Subtitle for the heading of the group configurations section.', + }, + containsGroups: { + id: 'course-authoring.group-configurations.container.contains-groups', + defaultMessage: 'Contains {len, plural, one {group} other {groups}}', + description: 'Message indicating the number of groups contained within a container.', + }, + notInUse: { + id: 'course-authoring.group-configurations.container.not-in-use', + defaultMessage: 'Not in use', + description: 'Message indicating that the group configurations are not currently in use.', + }, + usedInLocations: { + id: 'course-authoring.group-configurations.container.used-in-locations', + defaultMessage: 'Used in {len, plural, one {location} other {locations}}', + description: 'Message indicating the number of locations where the group configurations are used.', + }, +}); + +export default messages; diff --git a/src/group-configurations/utils.js b/src/group-configurations/utils.js new file mode 100644 index 0000000000..d701081fcc --- /dev/null +++ b/src/group-configurations/utils.js @@ -0,0 +1,53 @@ +import { getConfig } from '@edx/frontend-platform'; + +import messages from './messages'; + +/** + * Formats the given URL to a unit page URL. + * @param {string} url - The original part of URL. + * @returns {string} - The formatted unit page URL. + */ +const formatUrlToUnitPage = (url) => new URL(url, getConfig().STUDIO_BASE_URL).href; + +/** + * Retrieves a list of group count based on the number of items. + * @param {Array} items - The array of items to count. + * @param {function} formatMessage - The function for formatting localized messages. + * @returns {Array} - List of group count. + */ +const getGroupsCountMessage = (items, formatMessage) => { + if (!items?.length) { + return []; + } + + return [formatMessage(messages.containsGroups, { len: items.length })]; +}; + +/** + * Retrieves a list of usage count based on the number of items. + * @param {Array} items - The array of items to count. + * @param {function} formatMessage - The function for formatting localized messages. + * @returns {Array} - List of usage count. + */ +const getUsageCountMessage = (items, formatMessage) => { + if (!items?.length) { + return [formatMessage(messages.notInUse)]; + } + + return [formatMessage(messages.usedInLocations, { len: items.length })]; +}; + +/** + * Retrieves a combined list of badge messages based on usage and group information. + * @param {Array} usage - The array of items indicating usage. + * @param {Object} group - The group information. + * @param {boolean} isExperiment - Flag indicating whether it is an experiment group configurations. + * @param {function} formatMessage - The function for formatting localized messages. + * @returns {Array} - Combined list of badges. + */ +const getCombinedBadgeList = (usage, group, isExperiment, formatMessage) => [ + ...(isExperiment ? getGroupsCountMessage(group.groups, formatMessage) : []), + ...getUsageCountMessage(usage, formatMessage), +]; + +export { formatUrlToUnitPage, getCombinedBadgeList }; diff --git a/src/hooks.js b/src/hooks.js index 180d89fc6a..73597e3ef6 100644 --- a/src/hooks.js +++ b/src/hooks.js @@ -1,19 +1,23 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { history } from '@edx/frontend-platform'; export const useScrollToHashElement = ({ isLoading }) => { + const [elementWithHash, setElementWithHash] = useState(null); + useEffect(() => { - const currentHash = window.location.hash; + const currentHash = window.location.hash.substring(1); if (currentHash) { - const element = document.querySelector(currentHash); - + const element = document.getElementById(currentHash); if (element) { element.scrollIntoView(); history.replace({ hash: '' }); } + setElementWithHash(currentHash); } }, [isLoading]); + + return { elementWithHash }; }; export const useEscapeClick = ({ onEscape, dependency }) => { diff --git a/src/index.jsx b/src/index.jsx index f717df4c02..1f42b42bdf 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -8,7 +8,7 @@ import { AppProvider, ErrorPage } from '@edx/frontend-platform/react'; import React, { useEffect } from 'react'; import ReactDOM from 'react-dom'; import { - Navigate, Route, createRoutesFromElements, createBrowserRouter, RouterProvider, + Route, createRoutesFromElements, createBrowserRouter, RouterProvider, } from 'react-router-dom'; import { QueryClient, @@ -59,8 +59,6 @@ const App = () => { )} {getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && ( <> - {/* TODO: remove this redirect once Studio's link is updated */} - } /> }> } /> diff --git a/src/index.scss b/src/index.scss index 27e23358ca..bab24de4c9 100644 --- a/src/index.scss +++ b/src/index.scss @@ -27,3 +27,4 @@ @import "content-tags-drawer/ContentTagsCollapsible"; @import "search-modal/SearchModal"; @import "certificates/scss/Certificates"; +@import "group-configurations/GroupConfigurations"; diff --git a/src/pages-and-resources/discussions/DiscussionsSettings.jsx b/src/pages-and-resources/discussions/DiscussionsSettings.jsx index c3172e0610..18f9391779 100644 --- a/src/pages-and-resources/discussions/DiscussionsSettings.jsx +++ b/src/pages-and-resources/discussions/DiscussionsSettings.jsx @@ -96,6 +96,7 @@ const DiscussionsSettings = ({ courseId, intl }) => { onClose={handleClose} isOpen beforeBodyNode={} + isOverflowVisible={false} footerNode={( <> diff --git a/src/search-modal/SearchResult.jsx b/src/search-modal/SearchResult.jsx index 29e93dc3e7..725c4b1ab3 100644 --- a/src/search-modal/SearchResult.jsx +++ b/src/search-modal/SearchResult.jsx @@ -126,6 +126,7 @@ const SearchResult = ({ hit }) => { */ const getContextUrl = React.useCallback((newWindow = false) => { const { contextKey } = hit; + if (contextKey.startsWith('course-v1:')) { const urlSuffix = getUrlSuffix(hit); diff --git a/src/search-modal/data/api.js b/src/search-modal/data/api.js index 6fb949f282..e526546e69 100644 --- a/src/search-modal/data/api.js +++ b/src/search-modal/data/api.js @@ -85,7 +85,7 @@ function formatTagsFilter(tagsFilter) { * @property {string} org * @property {[{displayName: string}, ...Array<{displayName: string, usageKey: string}>]} breadcrumbs * First one is the name of the course/library itself. - * After that is the name of any parent Section/Subsection/Unit/etc. + * After that is the name and usage key of any parent Section/Subsection/Unit/etc. * @property {Record<'taxonomy'|'level0'|'level1'|'level2'|'level3', string[]>} tags * @property {ContentDetails} [content] * @property {{displayName: string, content: ContentDetails}} formatted Same fields with ... highlights diff --git a/src/store.js b/src/store.js index f527400cfb..30621bb62f 100644 --- a/src/store.js +++ b/src/store.js @@ -27,6 +27,7 @@ import { reducer as courseUnitReducer } from './course-unit/data/slice'; import { reducer as courseChecklistReducer } from './course-checklist/data/slice'; import { reducer as accessibilityPageReducer } from './accessibility-page/data/slice'; import { reducer as certificatesReducer } from './certificates/data/slice'; +import { reducer as groupConfigurationsReducer } from './group-configurations/data/slice'; export default function initializeStore(preloadedState = undefined) { return configureStore({ @@ -55,6 +56,7 @@ export default function initializeStore(preloadedState = undefined) { courseChecklist: courseChecklistReducer, accessibilityPage: accessibilityPageReducer, certificates: certificatesReducer, + groupConfigurations: groupConfigurationsReducer, }, preloadedState, });