From 422a5db6f9f8a86a962a74e03de1426a47a7970c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Ch=C3=A1vez?= Date: Wed, 17 Apr 2024 22:45:33 -0500 Subject: [PATCH 1/8] [FC-0036] feat: Sort taxonomies (#949) Taxonomies are now sorted by tag count for those with applied tags, and by name for the rest. --- src/content-tags-drawer/ContentTagsDrawer.jsx | 32 ++++++- .../ContentTagsDrawer.test.jsx | 95 +++++++++++++++++-- 2 files changed, 120 insertions(+), 7 deletions(-) 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]); + } + }); }); From bfcac5c0ddea6fbf8a0a91fd04a3697bc97466a4 Mon Sep 17 00:00:00 2001 From: Raymond Zhou <56318341+rayzhou-bit@users.noreply.github.com> Date: Thu, 18 Apr 2024 12:24:54 -0700 Subject: [PATCH 2/8] feat: bump flcc (#956) --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index ee9da9cd37..e46b24c7a3 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", @@ -2743,9 +2743,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 719d5fae4e..32987c4d0a 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", From 1834655399692649d4ab2cea5ff77e99a72fc8cd Mon Sep 17 00:00:00 2001 From: sundasnoreen12 <72802712+sundasnoreen12@users.noreply.github.com> Date: Mon, 22 Apr 2024 15:04:52 +0500 Subject: [PATCH 3/8] fix: fixed scroll issue of provider and settings (#958) --- src/pages-and-resources/discussions/DiscussionsSettings.jsx | 1 + 1 file changed, 1 insertion(+) 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={( <> From 6ec44b5f414d8d9344efd2e4828bb222762ceacf Mon Sep 17 00:00:00 2001 From: Ihor Romaniuk Date: Mon, 22 Apr 2024 17:13:16 +0200 Subject: [PATCH 4/8] feat: [FC-0044] Unit page - Manage access modal (unit & xblocks) (#901) * feat: [FC-0044] Unit page - Manage access modal (unit & xblocks) * fix: add message description --- src/constants.js | 7 + src/course-outline/CourseOutline.jsx | 12 +- src/course-outline/CourseOutline.scss | 1 - src/course-outline/CourseOutline.test.jsx | 5 +- src/course-unit/CourseUnit.jsx | 8 +- src/course-unit/CourseUnit.scss | 1 + src/course-unit/CourseUnit.test.jsx | 79 ++++- .../__mocks__/courseVerticalChildren.js | 136 ++++++++- .../course-xblock/CourseXBlock.jsx | 58 +++- .../course-xblock/CourseXBlock.scss | 33 +- .../course-xblock/CourseXBlock.test.jsx | 141 ++++++++- src/course-unit/course-xblock/constants.js | 5 + src/course-unit/course-xblock/messages.js | 17 ++ .../xblock-messages/XBlockMessages.jsx | 49 +++ .../xblock-messages/XBlockMessages.test.jsx | 55 ++++ .../course-xblock/xblock-messages/utils.js | 16 + .../xblock-messages/utils.test.js | 44 +++ src/course-unit/data/api.js | 8 +- src/course-unit/data/slice.js | 2 +- src/course-unit/data/thunk.js | 12 +- src/course-unit/data/utils.js | 5 +- src/course-unit/header-title/HeaderTitle.jsx | 99 +++--- src/course-unit/header-title/HeaderTitle.scss | 4 + .../header-title/HeaderTitle.test.jsx | 47 +++ src/course-unit/header-title/messages.js | 13 + src/course-unit/hooks.jsx | 8 + .../configure-modal/AdvancedTab.jsx | 0 .../configure-modal/BasicTab.jsx | 4 +- .../configure-modal/ConfigureModal.jsx | 80 ++++- .../configure-modal/ConfigureModal.scss | 0 .../configure-modal/ConfigureModal.test.jsx | 287 +++++------------- .../configure-modal/PrereqSettings.jsx | 2 +- .../configure-modal/UnitTab.jsx | 58 +++- .../configure-modal/VisibilityTab.jsx | 2 +- .../configure-modal/__mocks__/index.js | 199 ++++++++++++ .../configure-modal/messages.js | 32 +- src/generic/styles.scss | 1 + 37 files changed, 1201 insertions(+), 329 deletions(-) create mode 100644 src/course-unit/course-xblock/constants.js create mode 100644 src/course-unit/course-xblock/xblock-messages/XBlockMessages.jsx create mode 100644 src/course-unit/course-xblock/xblock-messages/XBlockMessages.test.jsx create mode 100644 src/course-unit/course-xblock/xblock-messages/utils.js create mode 100644 src/course-unit/course-xblock/xblock-messages/utils.test.js create mode 100644 src/course-unit/header-title/HeaderTitle.scss rename src/{course-outline => generic}/configure-modal/AdvancedTab.jsx (100%) rename src/{course-outline => generic}/configure-modal/BasicTab.jsx (96%) rename src/{course-outline => generic}/configure-modal/ConfigureModal.jsx (77%) rename src/{course-outline => generic}/configure-modal/ConfigureModal.scss (100%) rename src/{course-outline => generic}/configure-modal/ConfigureModal.test.jsx (56%) rename src/{course-outline => generic}/configure-modal/PrereqSettings.jsx (98%) rename src/{course-outline => generic}/configure-modal/UnitTab.jsx (70%) rename src/{course-outline => generic}/configure-modal/VisibilityTab.jsx (98%) create mode 100644 src/generic/configure-modal/__mocks__/index.js rename src/{course-outline => generic}/configure-modal/messages.js (93%) diff --git a/src/constants.js b/src/constants.js index 2913884a94..47c441b8a2 100644 --- a/src/constants.js +++ b/src/constants.js @@ -49,3 +49,10 @@ export const DECODED_ROUTES = { '/container/:blockId', ], }; + +export const COURSE_BLOCK_NAMES = ({ + chapter: { id: 'chapter', name: 'Section' }, + sequential: { id: 'sequential', name: 'Subsection' }, + vertical: { id: 'vertical', name: 'Unit' }, + component: { id: 'component', name: 'Component' }, +}); diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx index e046b78a54..fc1581687d 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.jsx @@ -29,9 +29,10 @@ import SubHeader from '../generic/sub-header/SubHeader'; import ProcessingNotification from '../generic/processing-notification'; import InternetConnectionAlert from '../generic/internet-connection-alert'; import DeleteModal from '../generic/delete-modal/DeleteModal'; +import ConfigureModal from '../generic/configure-modal/ConfigureModal'; import AlertMessage from '../generic/alert-message'; import getPageHeadTitle from '../generic/utils'; -import { getCurrentItem } from './data/selectors'; +import { getCurrentItem, getProctoredExamsFlag } from './data/selectors'; import { COURSE_BLOCK_NAMES } from './constants'; import HeaderNavigations from './header-navigations/HeaderNavigations'; import OutlineSideBar from './outline-sidebar/OutlineSidebar'; @@ -43,7 +44,6 @@ import UnitCard from './unit-card/UnitCard'; import HighlightsModal from './highlights-modal/HighlightsModal'; import EmptyPlaceholder from './empty-placeholder/EmptyPlaceholder'; import PublishModal from './publish-modal/PublishModal'; -import ConfigureModal from './configure-modal/ConfigureModal'; import PageAlerts from './page-alerts/PageAlerts'; import DraggableList from './drag-helper/DraggableList'; import { @@ -129,8 +129,10 @@ const CourseOutline = ({ courseId }) => { title: processingNotificationTitle, } = useSelector(getProcessingNotification); - const { category } = useSelector(getCurrentItem); - const deleteCategory = COURSE_BLOCK_NAMES[category]?.name.toLowerCase(); + const currentItemData = useSelector(getCurrentItem); + const deleteCategory = COURSE_BLOCK_NAMES[currentItemData.category]?.name.toLowerCase(); + + const enableProctoredExams = useSelector(getProctoredExamsFlag); /** * Move section to new index @@ -431,6 +433,8 @@ const CourseOutline = ({ courseId }) => { isOpen={isConfigureModalOpen} onClose={handleConfigureModalClose} onConfigureSubmit={handleConfigureItemSubmit} + currentItemData={currentItemData} + enableProctoredExams={enableProctoredExams} /> { handleTitleEdit, handleInternetConnectionFailed, handleCreateNewCourseXBlock, + handleConfigureSubmit, courseVerticalChildren, } = useCourseUnit({ courseId, blockId }); @@ -85,6 +86,7 @@ const CourseUnit = ({ courseId }) => { isTitleEditFormOpen={isTitleEditFormOpen} handleTitleEdit={handleTitleEdit} handleTitleEditSubmit={handleTitleEditSubmit} + handleConfigureSubmit={handleConfigureSubmit} /> )} breadcrumbs={( @@ -119,16 +121,20 @@ const CourseUnit = ({ courseId }) => { )} {courseVerticalChildren.children.map(({ - name, blockId: id, blockType: type, shouldScroll, + name, blockId: id, blockType: type, shouldScroll, userPartitionInfo, validationMessages, }) => ( ))} diff --git a/src/course-unit/CourseUnit.scss b/src/course-unit/CourseUnit.scss index 270691ecae..6e380bf9d2 100644 --- a/src/course-unit/CourseUnit.scss +++ b/src/course-unit/CourseUnit.scss @@ -3,3 +3,4 @@ @import "./add-component/AddComponent"; @import "./course-xblock/CourseXBlock"; @import "./sidebar/Sidebar"; +@import "./header-title/HeaderTitle"; diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 78b616ad7a..24d55a9e1e 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -38,13 +38,14 @@ import courseSequenceMessages from './course-sequence/messages'; import sidebarMessages from './sidebar/messages'; import { extractCourseUnitId } from './sidebar/utils'; import CourseUnit from './CourseUnit'; -import messages from './messages'; import deleteModalMessages from '../generic/delete-modal/messages'; +import configureModalMessages from '../generic/configure-modal/messages'; import courseXBlockMessages from './course-xblock/messages'; import addComponentMessages from './add-component/messages'; import { PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants'; import { getContentTaxonomyTagsApiUrl, getContentTaxonomyTagsCountApiUrl } from '../content-tags-drawer/data/api'; +import messages from './messages'; let axiosMock; let store; @@ -571,6 +572,7 @@ describe('', () => { name: 'New Cloned XBlock', block_id: '1234567890', block_type: 'drag-and-drop-v2', + user_partition_info: {}, }, ], }); @@ -594,7 +596,7 @@ describe('', () => { }); }); - it('should toggle visibility and update course unit state accordingly', async () => { + it('should toggle visibility from sidebar and update course unit state accordingly', async () => { const { getByRole, getByTestId } = render(); let courseUnitSidebar; let draftUnpublishedChangesHeading; @@ -617,7 +619,7 @@ describe('', () => { axiosMock .onPost(getXBlockBaseApiUrl(blockId), { publish: PUBLISH_TYPES.republish, - metadata: { visible_to_staff_only: true }, + metadata: { visible_to_staff_only: true, group_access: null }, }) .reply(200, { dummy: 'value' }); axiosMock @@ -654,7 +656,7 @@ describe('', () => { axiosMock .onPost(getXBlockBaseApiUrl(blockId), { publish: PUBLISH_TYPES.republish, - metadata: { visible_to_staff_only: null }, + metadata: { visible_to_staff_only: null, group_access: null }, }) .reply(200, { dummy: 'value' }); axiosMock @@ -942,4 +944,73 @@ describe('', () => { .replace('{sectionName}', courseUnitIndexMock.release_date_from), )).toBeInTheDocument(); }); + + it('should toggle visibility from header configure modal and update course unit state accordingly', async () => { + const { getByRole, getByTestId } = render(); + let courseUnitSidebar; + let sidebarVisibilityCheckbox; + let modalVisibilityCheckbox; + let configureModal; + let restrictAccessSelect; + + await waitFor(() => { + courseUnitSidebar = getByTestId('course-unit-sidebar'); + sidebarVisibilityCheckbox = within(courseUnitSidebar) + .getByLabelText(sidebarMessages.visibilityCheckboxTitle.defaultMessage); + expect(sidebarVisibilityCheckbox).not.toBeChecked(); + + const headerConfigureBtn = getByRole('button', { name: /settings/i }); + expect(headerConfigureBtn).toBeInTheDocument(); + + userEvent.click(headerConfigureBtn); + configureModal = getByTestId('configure-modal'); + restrictAccessSelect = within(configureModal) + .getByRole('combobox', { name: configureModalMessages.restrictAccessTo.defaultMessage }); + expect(within(configureModal) + .getByText(configureModalMessages.unitVisibility.defaultMessage)).toBeInTheDocument(); + expect(within(configureModal) + .getByText(configureModalMessages.restrictAccessTo.defaultMessage)).toBeInTheDocument(); + expect(restrictAccessSelect).toBeInTheDocument(); + expect(restrictAccessSelect).toHaveValue('-1'); + + modalVisibilityCheckbox = within(configureModal) + .getByRole('checkbox', { name: configureModalMessages.hideFromLearners.defaultMessage }); + expect(modalVisibilityCheckbox).not.toBeChecked(); + + userEvent.click(modalVisibilityCheckbox); + expect(modalVisibilityCheckbox).toBeChecked(); + + userEvent.selectOptions(restrictAccessSelect, '0'); + const [, group1Checkbox] = within(configureModal).getAllByRole('checkbox'); + + userEvent.click(group1Checkbox); + expect(group1Checkbox).toBeChecked(); + }); + + axiosMock + .onPost(getXBlockBaseApiUrl(courseUnitIndexMock.id), { + publish: null, + metadata: { visible_to_staff_only: true, group_access: { 50: [2] } }, + }) + .reply(200, { dummy: 'value' }); + axiosMock + .onGet(getCourseUnitApiUrl(blockId)) + .replyOnce(200, { + ...courseUnitIndexMock, + visibility_state: UNIT_VISIBILITY_STATES.staffOnly, + has_explicit_staff_lock: true, + }); + + const modalSaveBtn = within(configureModal) + .getByRole('button', { name: configureModalMessages.saveButton.defaultMessage }); + userEvent.click(modalSaveBtn); + + await waitFor(() => { + expect(sidebarVisibilityCheckbox).toBeChecked(); + expect(within(courseUnitSidebar) + .getByText(sidebarMessages.sidebarTitleVisibleToStaffOnly.defaultMessage)).toBeInTheDocument(); + expect(within(courseUnitSidebar) + .getByText(sidebarMessages.visibilityStaffOnlyTitle.defaultMessage)).toBeInTheDocument(); + }); + }); }); diff --git a/src/course-unit/__mocks__/courseVerticalChildren.js b/src/course-unit/__mocks__/courseVerticalChildren.js index d7cc9bf611..a6d8102dc5 100644 --- a/src/course-unit/__mocks__/courseVerticalChildren.js +++ b/src/course-unit/__mocks__/courseVerticalChildren.js @@ -2,14 +2,146 @@ module.exports = { children: [ { name: 'Discussion', - block_id: 'block-v1:OpenedX+L153+3T2023+type@discussion+block@fecd20842dd24f50bdc06643e791b013', + block_id: 'block-v1:OpenedX+L153+3T2023+type@discussion+block@5a28279f24344723a96b1268d3b7cfc0', block_type: 'discussion', + actions: { + can_copy: true, + can_duplicate: true, + can_move: true, + can_manage_access: true, + can_delete: true, + }, + user_partition_info: { + selectable_partitions: [ + { + id: 970807507, + name: 'Content Groups', + scheme: 'cohort', + groups: [ + { + id: 1959537066, + name: 'Group 1', + selected: false, + deleted: false, + }, + { + id: 108068059, + name: 'Group 2', + selected: false, + deleted: false, + }, + ], + }, + ], + selected_partition_index: -1, + selected_groups_label: '', + }, + user_partitions: [ + { + id: 970807507, + name: 'Content Groups', + scheme: 'cohort', + groups: [ + { + id: 1959537066, + name: 'Group 1', + selected: false, + deleted: false, + }, + { + id: 108068059, + name: 'Group 2', + selected: false, + deleted: false, + }, + ], + }, + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], }, { name: 'Drag and Drop', block_id: 'block-v1:OpenedX+L153+3T2023+type@drag-and-drop-v2+block@b33cf1f6df4c41639659bc91132eeb02', block_type: 'drag-and-drop-v2', + actions: { + can_copy: true, + can_duplicate: true, + can_move: true, + can_manage_access: true, + can_delete: true, + }, + user_partition_info: { + selectable_partitions: [ + { + id: 970807507, + name: 'Content Groups', + scheme: 'cohort', + groups: [ + { + id: 1959537066, + name: 'Group 1', + selected: false, + deleted: false, + }, + { + id: 108068059, + name: 'Group 2', + selected: false, + deleted: false, + }, + ], + }, + ], + selected_partition_index: -1, + selected_groups_label: '', + }, + user_partitions: [ + { + id: 970807507, + name: 'Content Groups', + scheme: 'cohort', + groups: [ + { + id: 1959537066, + name: 'Group 1', + selected: false, + deleted: false, + }, + { + id: 108068059, + name: 'Group 2', + selected: false, + deleted: false, + }, + ], + }, + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], }, ], - is_published: false, + isPublished: false, }; diff --git a/src/course-unit/course-xblock/CourseXBlock.jsx b/src/course-unit/course-xblock/CourseXBlock.jsx index 237a9c95ae..3f885692bc 100644 --- a/src/course-unit/course-xblock/CourseXBlock.jsx +++ b/src/course-unit/course-xblock/CourseXBlock.jsx @@ -9,21 +9,37 @@ import { useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; import DeleteModal from '../../generic/delete-modal/DeleteModal'; +import ConfigureModal from '../../generic/configure-modal/ConfigureModal'; import { scrollToElement } from '../../course-outline/utils'; +import { COURSE_BLOCK_NAMES } from '../../constants'; import { getCourseId } from '../data/selectors'; import { COMPONENT_TYPES } from '../constants'; +import XBlockMessages from './xblock-messages/XBlockMessages'; import messages from './messages'; const CourseXBlock = ({ - id, title, type, unitXBlockActions, shouldScroll, ...props + id, title, type, unitXBlockActions, shouldScroll, userPartitionInfo, + handleConfigureSubmit, validationMessages, ...props }) => { const courseXBlockElementRef = useRef(null); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); + const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); const navigate = useNavigate(); const courseId = useSelector(getCourseId); const intl = useIntl(); - const onXBlockDelete = () => { + const visibilityMessage = userPartitionInfo.selectedGroupsLabel + ? intl.formatMessage(messages.visibilityMessage, { selectedGroupsLabel: userPartitionInfo.selectedGroupsLabel }) + : null; + + const currentItemData = { + category: COURSE_BLOCK_NAMES.component.id, + displayName: title, + userPartitionInfo, + showCorrectness: 'always', + }; + + const onDeleteSubmit = () => { unitXBlockActions.handleDelete(id); closeDeleteModal(); }; @@ -39,6 +55,10 @@ const CourseXBlock = ({ } }; + const onConfigureSubmit = (...arg) => { + handleConfigureSubmit(id, ...arg, closeConfigureModal); + }; + useEffect(() => { // if this item has been newly added, scroll to it. if (courseXBlockElementRef.current && shouldScroll) { @@ -51,6 +71,7 @@ const CourseXBlock = ({ {intl.formatMessage(messages.blockLabelButtonMove)} - + {intl.formatMessage(messages.blockLabelButtonManageAccess)} @@ -90,13 +111,21 @@ const CourseXBlock = ({ category="component" isOpen={isDeleteModalOpen} close={closeDeleteModal} - onDeleteSubmit={onXBlockDelete} + onDeleteSubmit={onDeleteSubmit} + /> + )} size="md" /> +
@@ -105,6 +134,7 @@ const CourseXBlock = ({ }; CourseXBlock.defaultProps = { + validationMessages: [], shouldScroll: false, }; @@ -113,10 +143,30 @@ CourseXBlock.propTypes = { title: PropTypes.string.isRequired, type: PropTypes.string.isRequired, shouldScroll: PropTypes.bool, + validationMessages: PropTypes.arrayOf(PropTypes.shape({ + type: PropTypes.string, + text: PropTypes.string, + })), unitXBlockActions: PropTypes.shape({ handleDelete: PropTypes.func, handleDuplicate: PropTypes.func, }).isRequired, + userPartitionInfo: PropTypes.shape({ + selectablePartitions: PropTypes.arrayOf(PropTypes.shape({ + groups: PropTypes.arrayOf(PropTypes.shape({ + deleted: PropTypes.bool, + id: PropTypes.number, + name: PropTypes.string, + selected: PropTypes.bool, + })), + id: PropTypes.number, + name: PropTypes.string, + scheme: PropTypes.string, + })), + selectedPartitionIndex: PropTypes.number, + selectedGroupsLabel: PropTypes.string, + }).isRequired, + handleConfigureSubmit: PropTypes.func.isRequired, }; export default CourseXBlock; diff --git a/src/course-unit/course-xblock/CourseXBlock.scss b/src/course-unit/course-xblock/CourseXBlock.scss index 52c8e0bef5..262d19d653 100644 --- a/src/course-unit/course-xblock/CourseXBlock.scss +++ b/src/course-unit/course-xblock/CourseXBlock.scss @@ -1,15 +1,32 @@ .course-unit { - .pgn__card .pgn__card-header { - border-bottom: 1px solid $light-400; - padding-bottom: map-get($spacers, 2); + .course-unit__xblocks { + .pgn__card-header { + display: flex; + justify-content: space-between; + border-bottom: 1px solid $light-400; + padding-bottom: map-get($spacers, 2); - .pgn__card-header-content { - margin-top: map-get($spacers, 3\.5); + &:not(:has(.pgn__card-header-subtitle-md)) { + align-items: center; + } } - .btn-icon .btn-icon__icon { - width: 1.5rem; - height: 1.5rem; + .pgn__card-header-subtitle-md { + margin-top: 0; + font-size: $font-size-sm; } + + .pgn__card-header-title-md { + font: 700 1.375rem/1.75rem $font-family-sans-serif; + color: $black; + } + + .pgn__card-section { + padding: map-get($spacers, 3\.5) 0; + } + } + + .unit-iframe__wrapper .alert-danger { + margin-bottom: 0; } } diff --git a/src/course-unit/course-xblock/CourseXBlock.test.jsx b/src/course-unit/course-xblock/CourseXBlock.test.jsx index 6be85f150c..ad8e09184b 100644 --- a/src/course-unit/course-xblock/CourseXBlock.test.jsx +++ b/src/course-unit/course-xblock/CourseXBlock.test.jsx @@ -1,18 +1,27 @@ -import { render, waitFor } from '@testing-library/react'; +import { + render, waitFor, within, +} from '@testing-library/react'; import { useSelector } from 'react-redux'; import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform'; import { AppProvider } from '@edx/frontend-platform/react'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import configureModalMessages from '../../generic/configure-modal/messages'; +import deleteModalMessages from '../../generic/delete-modal/messages'; +import initializeStore from '../../store'; +import { getCourseSectionVerticalApiUrl, getXBlockBaseApiUrl } from '../data/api'; +import { fetchCourseSectionVerticalData } from '../data/thunk'; +import { executeThunk } from '../../utils'; import { getCourseId } from '../data/selectors'; -import { COMPONENT_TYPES } from '../constants'; -import { courseVerticalChildrenMock } from '../__mocks__'; +import { PUBLISH_TYPES, COMPONENT_TYPES } from '../constants'; +import { courseSectionVerticalMock, courseVerticalChildrenMock } from '../__mocks__'; import CourseXBlock from './CourseXBlock'; - -import deleteModalMessages from '../../generic/delete-modal/messages'; import messages from './messages'; +let axiosMock; let store; const courseId = '1234'; const blockId = '567890'; @@ -26,6 +35,7 @@ const { block_type: type, user_partition_info: userPartitionInfo, } = courseVerticalChildrenMock.children[0]; +const userPartitionInfoFormatted = camelCaseObject(userPartitionInfo); const unitXBlockActionsMock = { handleDelete: handleDeleteMock, handleDuplicate: handleDuplicateMock, @@ -50,7 +60,7 @@ const renderComponent = (props) => render( type={type} blockId={blockId} unitXBlockActions={unitXBlockActionsMock} - userPartitionInfo={camelCaseObject(userPartitionInfo)} + userPartitionInfo={userPartitionInfoFormatted} shouldScroll={false} handleConfigureSubmit={handleConfigureSubmitMock} {...props} @@ -76,6 +86,13 @@ describe('', () => { roles: [], }, }); + + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, courseSectionVerticalMock); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); }); it('render CourseXBlock component correctly', async () => { @@ -93,7 +110,6 @@ describe('', () => { await waitFor(() => { userEvent.click(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage)); - expect(getByRole('button', { name: messages.blockLabelButtonCopy.defaultMessage })).toBeInTheDocument(); expect(getByRole('button', { name: messages.blockLabelButtonDuplicate.defaultMessage })).toBeInTheDocument(); expect(getByRole('button', { name: messages.blockLabelButtonMove.defaultMessage })).toBeInTheDocument(); expect(getByRole('button', { name: messages.blockLabelButtonManageAccess.defaultMessage })).toBeInTheDocument(); @@ -181,6 +197,117 @@ describe('', () => { userEvent.click(editButton); expect(mockedUsedNavigate).toHaveBeenCalled(); expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseId}/editor/problem/${id}`); + expect(handleDeleteMock).toHaveBeenCalledWith(id); + }); + }); + + describe('restrict access', () => { + it('opens restrict access modal successfully', async () => { + const { + getByText, + getByLabelText, + findByTestId, + } = renderComponent(); + + const modalSubtitleText = configureModalMessages.restrictAccessTo.defaultMessage; + const modalCancelBtnText = configureModalMessages.cancelButton.defaultMessage; + const modalSaveBtnText = configureModalMessages.saveButton.defaultMessage; + + userEvent.click(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage)); + const accessBtn = getByText(messages.blockLabelButtonManageAccess.defaultMessage); + + userEvent.click(accessBtn); + const configureModal = await findByTestId('configure-modal'); + + expect(within(configureModal).getByText(modalSubtitleText)).toBeInTheDocument(); + expect(within(configureModal).getByRole('button', { name: modalCancelBtnText })).toBeInTheDocument(); + expect(within(configureModal).getByRole('button', { name: modalSaveBtnText })).toBeInTheDocument(); + }); + + it('closes restrict access modal when cancel button is clicked', async () => { + const { + getByText, + getByLabelText, + findByTestId, + } = renderComponent(); + + userEvent.click(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage)); + const accessBtn = getByText(messages.blockLabelButtonManageAccess.defaultMessage); + + userEvent.click(accessBtn); + const configureModal = await findByTestId('configure-modal'); + expect(configureModal).toBeInTheDocument(); + + userEvent.click(within(configureModal).getByRole('button', { name: configureModalMessages.saveButton.defaultMessage })); + expect(handleConfigureSubmitMock).not.toHaveBeenCalled(); + }); + + it('handles submit restrict access data when save button is clicked', async () => { + axiosMock + .onPost(getXBlockBaseApiUrl(id), { + publish: PUBLISH_TYPES.republish, + metadata: { visible_to_staff_only: false, group_access: { 970807507: [1959537066] } }, + }) + .reply(200, { dummy: 'value' }); + + const { + getByText, + getByLabelText, + findByTestId, + getByRole, + } = renderComponent(); + const accessGroupName1 = userPartitionInfoFormatted.selectablePartitions[0].groups[0].name; + const accessGroupName2 = userPartitionInfoFormatted.selectablePartitions[0].groups[1].name; + + userEvent.click(getByLabelText(messages.blockActionsDropdownAlt.defaultMessage)); + const accessBtn = getByText(messages.blockLabelButtonManageAccess.defaultMessage); + + userEvent.click(accessBtn); + const configureModal = await findByTestId('configure-modal'); + expect(configureModal).toBeInTheDocument(); + expect(within(configureModal).queryByText(accessGroupName1)).not.toBeInTheDocument(); + expect(within(configureModal).queryByText(accessGroupName2)).not.toBeInTheDocument(); + + const restrictAccessSelect = getByRole('combobox', { + name: configureModalMessages.restrictAccessTo.defaultMessage, + }); + userEvent.selectOptions(restrictAccessSelect, '0'); + + // eslint-disable-next-line array-callback-return + userPartitionInfoFormatted.selectablePartitions[0].groups.map((group) => { + expect(within(configureModal).getByRole('checkbox', { name: group.name })).not.toBeChecked(); + expect(within(configureModal).queryByText(group.name)).toBeInTheDocument(); + }); + + const group1Checkbox = within(configureModal).getByRole('checkbox', { name: accessGroupName1 }); + userEvent.click(group1Checkbox); + expect(group1Checkbox).toBeChecked(); + + const saveModalBtnText = within(configureModal).getByRole('button', { + name: configureModalMessages.saveButton.defaultMessage, + }); + expect(saveModalBtnText).toBeInTheDocument(); + userEvent.click(saveModalBtnText); + await waitFor(() => { + expect(handleConfigureSubmitMock).toHaveBeenCalledTimes(1); + }); + }); + }); + + it('displays a visibility message if item has accessible restrictions', async () => { + const { getByText } = renderComponent( + { + userPartitionInfo: { + ...userPartitionInfoFormatted, + selectedGroupsLabel: 'Visibility group 1', + }, + }, + ); + + await waitFor(() => { + const visibilityMessage = messages.visibilityMessage.defaultMessage + .replace('{selectedGroupsLabel}', 'Visibility group 1'); + expect(getByText(visibilityMessage)).toBeInTheDocument(); }); }); }); diff --git a/src/course-unit/course-xblock/constants.js b/src/course-unit/course-xblock/constants.js new file mode 100644 index 0000000000..5f0177ce72 --- /dev/null +++ b/src/course-unit/course-xblock/constants.js @@ -0,0 +1,5 @@ +// eslint-disable-next-line import/prefer-default-export +export const MESSAGE_ERROR_TYPES = { + error: 'error', + warning: 'warning', +}; diff --git a/src/course-unit/course-xblock/messages.js b/src/course-unit/course-xblock/messages.js index e4b6365424..1b78bfcc91 100644 --- a/src/course-unit/course-xblock/messages.js +++ b/src/course-unit/course-xblock/messages.js @@ -4,30 +4,47 @@ const messages = defineMessages({ blockAltButtonEdit: { id: 'course-authoring.course-unit.xblock.button.edit.alt', defaultMessage: 'Edit', + description: 'The xblock edit button text', }, blockActionsDropdownAlt: { id: 'course-authoring.course-unit.xblock.button.actions.alt', defaultMessage: 'Actions', + description: 'The xblock three dots dropdown alt text', }, blockLabelButtonCopy: { id: 'course-authoring.course-unit.xblock.button.copy.label', defaultMessage: 'Copy', + description: 'The xblock copy button text', }, blockLabelButtonDuplicate: { id: 'course-authoring.course-unit.xblock.button.duplicate.label', defaultMessage: 'Duplicate', + description: 'The xblock duplicate button text', }, blockLabelButtonMove: { id: 'course-authoring.course-unit.xblock.button.move.label', defaultMessage: 'Move', + description: 'The xblock move button text', }, blockLabelButtonManageAccess: { id: 'course-authoring.course-unit.xblock.button.manageAccess.label', defaultMessage: 'Manage access', + description: 'The xblock manage access button text', }, blockLabelButtonDelete: { id: 'course-authoring.course-unit.xblock.button.delete.label', defaultMessage: 'Delete', + description: 'The xblock delete button text', + }, + visibilityMessage: { + id: 'course-authoring.course-unit.xblock.visibility.message', + defaultMessage: 'Access restricted to: {selectedGroupsLabel}', + description: 'Group visibility accessibility text for xblock', + }, + validationSummary: { + id: 'course-authoring.course-unit.xblock.validation.summary', + defaultMessage: 'This component has validation issues.', + description: 'The alert text of the visibility validation issues', }, }); diff --git a/src/course-unit/course-xblock/xblock-messages/XBlockMessages.jsx b/src/course-unit/course-xblock/xblock-messages/XBlockMessages.jsx new file mode 100644 index 0000000000..0d7e32a4b1 --- /dev/null +++ b/src/course-unit/course-xblock/xblock-messages/XBlockMessages.jsx @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import { Alert } from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Info as InfoIcon, WarningFilled as WarningIcon } from '@openedx/paragon/icons'; + +import messages from '../messages'; +import { MESSAGE_ERROR_TYPES } from '../constants'; +import { getMessagesBlockType } from './utils'; + +const XBlockMessages = ({ validationMessages }) => { + const intl = useIntl(); + const type = getMessagesBlockType(validationMessages); + const { warning } = MESSAGE_ERROR_TYPES; + const alertVariant = type === warning ? 'warning' : 'danger'; + const alertIcon = type === warning ? WarningIcon : InfoIcon; + + if (!validationMessages.length) { + return null; + } + + return ( + + + {intl.formatMessage(messages.validationSummary)} + +
    + {validationMessages.map(({ text }) => ( +
  • {text}
  • + ))} +
+
+ ); +}; + +XBlockMessages.defaultProps = { + validationMessages: [], +}; + +XBlockMessages.propTypes = { + validationMessages: PropTypes.arrayOf(PropTypes.shape({ + type: PropTypes.string, + text: PropTypes.string, + })), +}; + +export default XBlockMessages; diff --git a/src/course-unit/course-xblock/xblock-messages/XBlockMessages.test.jsx b/src/course-unit/course-xblock/xblock-messages/XBlockMessages.test.jsx new file mode 100644 index 0000000000..8d7e36e98a --- /dev/null +++ b/src/course-unit/course-xblock/xblock-messages/XBlockMessages.test.jsx @@ -0,0 +1,55 @@ +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import messages from '../messages'; +import XBlockMessages from './XBlockMessages'; + +const renderComponent = (props) => render( + + + , +); + +describe('', () => { + it('renders without errors', () => { + renderComponent({ validationMessages: [] }); + }); + + it('does not render anything when there are no errors', () => { + const { container } = renderComponent({ validationMessages: [] }); + expect(container.firstChild).toBeNull(); + }); + + it('renders a warning Alert when there are warning errors', () => { + const validationMessages = [{ type: 'warning', text: 'This is a warning' }]; + const { getByText } = renderComponent({ validationMessages }); + + expect(getByText('This is a warning')).toBeInTheDocument(); + expect(getByText(messages.validationSummary.defaultMessage)).toBeInTheDocument(); + }); + + it('renders a danger Alert when there are danger errors', () => { + const validationMessages = [{ type: 'danger', text: 'This is a danger' }]; + const { getByText } = renderComponent({ validationMessages }); + + expect(getByText('This is a danger')).toBeInTheDocument(); + expect(getByText(messages.validationSummary.defaultMessage)).toBeInTheDocument(); + }); + + it('renders multiple error messages in a list', () => { + const validationMessages = [ + { type: 'warning', text: 'Warning 1' }, + { type: 'danger', text: 'Danger 1' }, + { type: 'danger', text: 'Danger 2' }, + ]; + const { getByText } = renderComponent({ validationMessages }); + + expect(getByText('Warning 1')).toBeInTheDocument(); + expect(getByText('Danger 1')).toBeInTheDocument(); + expect(getByText('Danger 2')).toBeInTheDocument(); + expect(getByText(messages.validationSummary.defaultMessage)).toBeInTheDocument(); + }); +}); diff --git a/src/course-unit/course-xblock/xblock-messages/utils.js b/src/course-unit/course-xblock/xblock-messages/utils.js new file mode 100644 index 0000000000..2a815b7aa2 --- /dev/null +++ b/src/course-unit/course-xblock/xblock-messages/utils.js @@ -0,0 +1,16 @@ +import { MESSAGE_ERROR_TYPES } from '../constants'; + +/** + * Determines the block type based on the types of messages in the given array. + * @param {Array} messages - An array of message objects. + * @param {Object[]} messages.type - The type of each message (e.g., MESSAGE_ERROR_TYPES.error). + * @returns {string} - The block type determined by the messages (e.g., 'warning' or 'error'). + */ +// eslint-disable-next-line import/prefer-default-export +export const getMessagesBlockType = (messages) => { + let type = MESSAGE_ERROR_TYPES.warning; + if (messages.some((message) => message.type === MESSAGE_ERROR_TYPES.error)) { + type = MESSAGE_ERROR_TYPES.error; + } + return type; +}; diff --git a/src/course-unit/course-xblock/xblock-messages/utils.test.js b/src/course-unit/course-xblock/xblock-messages/utils.test.js new file mode 100644 index 0000000000..32e8dde4f6 --- /dev/null +++ b/src/course-unit/course-xblock/xblock-messages/utils.test.js @@ -0,0 +1,44 @@ +import { MESSAGE_ERROR_TYPES } from '../constants'; +import { getMessagesBlockType } from './utils'; + +describe('xblock-messages utils', () => { + describe('getMessagesBlockType', () => { + it('returns "warning" when there are no error messages', () => { + const messages = [ + { type: MESSAGE_ERROR_TYPES.warning, text: 'This is a warning' }, + { type: MESSAGE_ERROR_TYPES.warning, text: 'Another warning' }, + ]; + const result = getMessagesBlockType(messages); + + expect(result).toBe(MESSAGE_ERROR_TYPES.warning); + }); + + it('returns "error" when there is at least one error message', () => { + const messages = [ + { type: MESSAGE_ERROR_TYPES.warning, text: 'This is a warning' }, + { type: MESSAGE_ERROR_TYPES.error, text: 'This is an error' }, + { type: MESSAGE_ERROR_TYPES.warning, text: 'Another warning' }, + ]; + const result = getMessagesBlockType(messages); + + expect(result).toBe(MESSAGE_ERROR_TYPES.error); + }); + + it('returns "error" when there are only error messages', () => { + const messages = [ + { type: MESSAGE_ERROR_TYPES.error, text: 'This is an error' }, + { type: MESSAGE_ERROR_TYPES.error, text: 'Another error' }, + ]; + const result = getMessagesBlockType(messages); + + expect(result).toBe(MESSAGE_ERROR_TYPES.error); + }); + + it('returns "warning" when there are no messages', () => { + const messages = []; + const result = getMessagesBlockType(messages); + + expect(result).toBe(MESSAGE_ERROR_TYPES.warning); + }); + }); +}); diff --git a/src/course-unit/data/api.js b/src/course-unit/data/api.js index 6520d1e1de..3ec12cef43 100644 --- a/src/course-unit/data/api.js +++ b/src/course-unit/data/api.js @@ -88,14 +88,16 @@ export async function createCourseXblock({ * @param {string} unitId - The ID of the course unit. * @param {string} type - The action type (e.g., PUBLISH_TYPES.discardChanges). * @param {boolean} isVisible - The visibility status for students. + * @param {boolean} groupAccess - Access group key set. * @returns {Promise} A promise that resolves with the response data. */ -export async function handleCourseUnitVisibilityAndData(unitId, type, isVisible) { +export async function handleCourseUnitVisibilityAndData(unitId, type, isVisible, groupAccess) { const body = { - publish: type, + publish: groupAccess ? null : type, ...(type === PUBLISH_TYPES.republish ? { metadata: { - visible_to_staff_only: isVisible, + visible_to_staff_only: isVisible ? true : null, + group_access: groupAccess || null, }, } : {}), }; diff --git a/src/course-unit/data/slice.js b/src/course-unit/data/slice.js index 0134fcb054..436a957b2b 100644 --- a/src/course-unit/data/slice.js +++ b/src/course-unit/data/slice.js @@ -16,7 +16,7 @@ const slice = createSlice({ }, unit: {}, courseSectionVertical: {}, - courseVerticalChildren: [], + courseVerticalChildren: {}, }, reducers: { fetchCourseItemSuccess: (state, { payload }) => { diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js index e18a0cc6d6..6d63531881 100644 --- a/src/course-unit/data/thunk.js +++ b/src/course-unit/data/thunk.js @@ -111,19 +111,19 @@ export function editCourseItemQuery(itemId, displayName, sequenceId) { }; } -export function editCourseUnitVisibilityAndData(itemId, type, isVisible) { +export function editCourseUnitVisibilityAndData(itemId, type, isVisible, groupAccess, isModalView, blockId = itemId) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); dispatch(updateQueryPendingStatus(true)); - const notificationMessage = getNotificationMessage(type, isVisible); - dispatch(showProcessingNotification(notificationMessage)); + const notification = getNotificationMessage(type, isVisible, isModalView); + dispatch(showProcessingNotification(notification)); try { - await handleCourseUnitVisibilityAndData(itemId, type, isVisible).then(async (result) => { + await handleCourseUnitVisibilityAndData(itemId, type, isVisible, groupAccess).then(async (result) => { if (result) { - const courseUnit = await getCourseUnitData(itemId); + const courseUnit = await getCourseUnitData(blockId); dispatch(fetchCourseItemSuccess(courseUnit)); - const courseVerticalChildrenData = await getCourseVerticalChildren(itemId); + const courseVerticalChildrenData = await getCourseVerticalChildren(blockId); dispatch(updateCourseVerticalChildren(courseVerticalChildrenData)); dispatch(hideProcessingNotification()); dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); diff --git a/src/course-unit/data/utils.js b/src/course-unit/data/utils.js index a37faaa4db..49223e1a7d 100644 --- a/src/course-unit/data/utils.js +++ b/src/course-unit/data/utils.js @@ -30,15 +30,18 @@ export function normalizeCourseSectionVerticalData(metadata) { * Get the notification message based on the publishing type and visibility. * @param {string} type - The publishing type. * @param {boolean} isVisible - The visibility status. + * @param {boolean} isModalView - The modal view status. * @returns {string} The corresponding notification message. */ -export const getNotificationMessage = (type, isVisible) => { +export const getNotificationMessage = (type, isVisible, isModalView) => { let notificationMessage; if (type === PUBLISH_TYPES.discardChanges) { notificationMessage = NOTIFICATION_MESSAGES.discardChanges; } else if (type === PUBLISH_TYPES.makePublic) { notificationMessage = NOTIFICATION_MESSAGES.publishing; + } else if (type === PUBLISH_TYPES.republish && isModalView) { + notificationMessage = NOTIFICATION_MESSAGES.saving; } else if (type === PUBLISH_TYPES.republish && !isVisible) { notificationMessage = NOTIFICATION_MESSAGES.makingVisibleToStudents; } else if (type === PUBLISH_TYPES.republish && isVisible) { diff --git a/src/course-unit/header-title/HeaderTitle.jsx b/src/course-unit/header-title/HeaderTitle.jsx index 4fc5739225..0d29404ba6 100644 --- a/src/course-unit/header-title/HeaderTitle.jsx +++ b/src/course-unit/header-title/HeaderTitle.jsx @@ -1,13 +1,15 @@ import { useEffect, useState } from 'react'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Form, IconButton } from '@openedx/paragon'; +import { Form, IconButton, useToggle } from '@openedx/paragon'; import { EditOutline as EditIcon, Settings as SettingsIcon, } from '@openedx/paragon/icons'; +import ConfigureModal from '../../generic/configure-modal/ConfigureModal'; +import { getCourseUnitData } from '../data/selectors'; import { updateQueryPendingStatus } from '../data/slice'; import messages from './messages'; @@ -16,10 +18,30 @@ const HeaderTitle = ({ isTitleEditFormOpen, handleTitleEdit, handleTitleEditSubmit, + handleConfigureSubmit, }) => { const intl = useIntl(); const dispatch = useDispatch(); const [titleValue, setTitleValue] = useState(unitTitle); + const currentItemData = useSelector(getCourseUnitData); + const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); + const { selectedPartitionIndex, selectedGroupsLabel } = currentItemData.userPartitionInfo; + + const onConfigureSubmit = (...arg) => { + handleConfigureSubmit(currentItemData.id, ...arg, closeConfigureModal); + }; + + const getVisibilityMessage = () => { + let message; + + if (selectedPartitionIndex !== -1 && !Number.isNaN(selectedPartitionIndex) && selectedGroupsLabel) { + message = intl.formatMessage(messages.definedVisibilityMessage, { selectedGroupsLabel }); + } else if (currentItemData.hasPartitionGroupComponents) { + message = intl.formatMessage(messages.commonVisibilityMessage); + } + + return message ? (

{message}

) : null; + }; useEffect(() => { setTitleValue(unitTitle); @@ -27,38 +49,46 @@ const HeaderTitle = ({ }, [unitTitle]); return ( -
- {isTitleEditFormOpen ? ( - - e && e.focus()} - value={titleValue} - name="displayName" - onChange={(e) => setTitleValue(e.target.value)} - aria-label={intl.formatMessage(messages.ariaLabelButtonEdit)} - onBlur={() => handleTitleEditSubmit(titleValue)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - handleTitleEditSubmit(titleValue); - } - }} - /> - - ) : unitTitle} - - { - }} - /> -
+ <> +
+ {isTitleEditFormOpen ? ( + + e && e.focus()} + value={titleValue} + name="displayName" + onChange={(e) => setTitleValue(e.target.value)} + aria-label={intl.formatMessage(messages.ariaLabelButtonEdit)} + onBlur={() => handleTitleEditSubmit(titleValue)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleTitleEditSubmit(titleValue); + } + }} + /> + + ) : unitTitle} + + + +
+ {getVisibilityMessage()} + ); }; @@ -67,6 +97,7 @@ HeaderTitle.propTypes = { isTitleEditFormOpen: PropTypes.bool.isRequired, handleTitleEdit: PropTypes.func.isRequired, handleTitleEditSubmit: PropTypes.func.isRequired, + handleConfigureSubmit: PropTypes.func.isRequired, }; export default HeaderTitle; diff --git a/src/course-unit/header-title/HeaderTitle.scss b/src/course-unit/header-title/HeaderTitle.scss new file mode 100644 index 0000000000..753c3b7184 --- /dev/null +++ b/src/course-unit/header-title/HeaderTitle.scss @@ -0,0 +1,4 @@ +.header-title__visibility-message { + font-size: $font-size-sm; + font-weight: $font-weight-normal; +} diff --git a/src/course-unit/header-title/HeaderTitle.test.jsx b/src/course-unit/header-title/HeaderTitle.test.jsx index b5014f89c2..7e57c408e0 100644 --- a/src/course-unit/header-title/HeaderTitle.test.jsx +++ b/src/course-unit/header-title/HeaderTitle.test.jsx @@ -1,3 +1,5 @@ +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@edx/frontend-platform/i18n'; @@ -5,14 +7,21 @@ import { AppProvider } from '@edx/frontend-platform/react'; import { initializeMockApp } from '@edx/frontend-platform'; import initializeStore from '../../store'; +import { executeThunk } from '../../utils'; +import { getCourseUnitApiUrl } from '../data/api'; +import { fetchCourseUnitQuery } from '../data/thunk'; +import { courseUnitIndexMock } from '../__mocks__'; import HeaderTitle from './HeaderTitle'; import messages from './messages'; +const blockId = '123'; const unitTitle = 'Getting Started'; const isTitleEditFormOpen = false; const handleTitleEdit = jest.fn(); const handleTitleEditSubmit = jest.fn(); +const handleConfigureSubmit = jest.fn(); let store; +let axiosMock; const renderComponent = (props) => render( @@ -22,6 +31,7 @@ const renderComponent = (props) => render( isTitleEditFormOpen={isTitleEditFormOpen} handleTitleEdit={handleTitleEdit} handleTitleEditSubmit={handleTitleEditSubmit} + handleConfigureSubmit={handleConfigureSubmit} {...props} /> @@ -40,6 +50,11 @@ describe('', () => { }); store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock + .onGet(getCourseUnitApiUrl(blockId)) + .reply(200, courseUnitIndexMock); + await executeThunk(fetchCourseUnitQuery(blockId), store.dispatch); }); it('render HeaderTitle component correctly', () => { @@ -85,4 +100,36 @@ describe('', () => { expect(titleField).toHaveValue(`${unitTitle} 1 2`); expect(handleTitleEditSubmit).toHaveBeenCalledTimes(2); }); + + it('displays a visibility message with the selected groups for the unit', async () => { + axiosMock + .onGet(getCourseUnitApiUrl(blockId)) + .reply(200, { + ...courseUnitIndexMock, + user_partition_info: { + ...courseUnitIndexMock.user_partition_info, + selected_partition_index: '1', + selected_groups_label: 'Visibility group 1', + }, + }); + await executeThunk(fetchCourseUnitQuery(blockId), store.dispatch); + const { getByText } = renderComponent(); + const visibilityMessage = messages.definedVisibilityMessage.defaultMessage + .replace('{selectedGroupsLabel}', 'Visibility group 1'); + + expect(getByText(visibilityMessage)).toBeInTheDocument(); + }); + + it('displays a visibility message with the selected groups for some of xblock', async () => { + axiosMock + .onGet(getCourseUnitApiUrl(blockId)) + .reply(200, { + ...courseUnitIndexMock, + has_partition_group_components: true, + }); + await executeThunk(fetchCourseUnitQuery(blockId), store.dispatch); + const { getByText } = renderComponent(); + + expect(getByText(messages.commonVisibilityMessage.defaultMessage)).toBeInTheDocument(); + }); }); diff --git a/src/course-unit/header-title/messages.js b/src/course-unit/header-title/messages.js index c6ca9ef208..036e9ddef8 100644 --- a/src/course-unit/header-title/messages.js +++ b/src/course-unit/header-title/messages.js @@ -4,14 +4,27 @@ const messages = defineMessages({ altButtonEdit: { id: 'course-authoring.course-unit.heading.button.edit.alt', defaultMessage: 'Edit', + description: 'The unit edit button text', }, ariaLabelButtonEdit: { id: 'course-authoring.course-unit.heading.button.edit.aria-label', defaultMessage: 'Edit field', + description: 'The unit edit button aria label', }, altButtonSettings: { id: 'course-authoring.course-unit.heading.button.settings.alt', defaultMessage: 'Settings', + description: 'The unit settings button text', + }, + definedVisibilityMessage: { + id: 'course-authoring.course-unit.heading.visibility.defined.message', + defaultMessage: 'Access to this unit is restricted to: {selectedGroupsLabel}', + description: 'Group visibility accessibility text for Unit', + }, + commonVisibilityMessage: { + id: 'course-authoring.course-unit.heading.visibility.common.message', + defaultMessage: 'Access to some content in this unit is restricted to specific groups of learners.', + description: 'The label text of some content restriction in this unit', }, }); diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index d1d1edc7bc..a2b0726e71 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -11,6 +11,7 @@ import { fetchCourseVerticalChildrenData, deleteUnitItemQuery, duplicateUnitItemQuery, + editCourseUnitVisibilityAndData, } from './data/thunk'; import { getCourseSectionVertical, @@ -21,6 +22,7 @@ import { getSequenceStatus, } from './data/selectors'; import { changeEditTitleFormOpen, updateQueryPendingStatus } from './data/slice'; +import { PUBLISH_TYPES } from './constants'; // eslint-disable-next-line import/prefer-default-export export const useCourseUnit = ({ courseId, blockId }) => { @@ -59,6 +61,11 @@ export const useCourseUnit = ({ courseId, blockId }) => { dispatch(changeEditTitleFormOpen(!isTitleEditFormOpen)); }; + const handleConfigureSubmit = (id, isVisible, groupAccess, closeModalFn) => { + dispatch(editCourseUnitVisibilityAndData(id, PUBLISH_TYPES.republish, isVisible, groupAccess, true, blockId)); + closeModalFn(); + }; + const handleTitleEditSubmit = (displayName) => { if (unitTitle !== displayName) { dispatch(editCourseItemQuery(blockId, displayName, sequenceId)); @@ -121,6 +128,7 @@ export const useCourseUnit = ({ courseId, blockId }) => { handleTitleEdit, handleTitleEditSubmit, handleCreateNewCourseXBlock, + handleConfigureSubmit, courseVerticalChildren, }; }; diff --git a/src/course-outline/configure-modal/AdvancedTab.jsx b/src/generic/configure-modal/AdvancedTab.jsx similarity index 100% rename from src/course-outline/configure-modal/AdvancedTab.jsx rename to src/generic/configure-modal/AdvancedTab.jsx diff --git a/src/course-outline/configure-modal/BasicTab.jsx b/src/generic/configure-modal/BasicTab.jsx similarity index 96% rename from src/course-outline/configure-modal/BasicTab.jsx rename to src/generic/configure-modal/BasicTab.jsx index 173bc34939..182de34df1 100644 --- a/src/course-outline/configure-modal/BasicTab.jsx +++ b/src/generic/configure-modal/BasicTab.jsx @@ -1,9 +1,9 @@ -import React from 'react'; import PropTypes from 'prop-types'; import { Stack, Form } from '@openedx/paragon'; import { FormattedMessage, injectIntl, useIntl } from '@edx/frontend-platform/i18n'; + +import { DatepickerControl, DATEPICKER_TYPES } from '../datepicker-control'; import messages from './messages'; -import { DatepickerControl, DATEPICKER_TYPES } from '../../generic/datepicker-control'; const BasicTab = ({ values, diff --git a/src/course-outline/configure-modal/ConfigureModal.jsx b/src/generic/configure-modal/ConfigureModal.jsx similarity index 77% rename from src/course-outline/configure-modal/ConfigureModal.jsx rename to src/generic/configure-modal/ConfigureModal.jsx index 3e7556b466..a78bd386a7 100644 --- a/src/course-outline/configure-modal/ConfigureModal.jsx +++ b/src/generic/configure-modal/ConfigureModal.jsx @@ -11,12 +11,10 @@ import { Tab, Tabs, } from '@openedx/paragon'; -import { useSelector } from 'react-redux'; import { Formik } from 'formik'; import { VisibilityTypes } from '../../data/constants'; -import { COURSE_BLOCK_NAMES } from '../constants'; -import { getCurrentItem, getProctoredExamsFlag } from '../data/selectors'; +import { COURSE_BLOCK_NAMES } from '../../constants'; import messages from './messages'; import BasicTab from './BasicTab'; import VisibilityTab from './VisibilityTab'; @@ -27,6 +25,9 @@ const ConfigureModal = ({ isOpen, onClose, onConfigureSubmit, + currentItemData, + enableProctoredExams, + isXBlockComponent, }) => { const intl = useIntl(); const { @@ -57,8 +58,7 @@ const ConfigureModal = ({ supportsOnboarding, showReviewRules, onlineProctoringRules, - } = useSelector(getCurrentItem); - const enableProctoredExams = useSelector(getProctoredExamsFlag); + } = currentItemData; const getSelectedGroups = () => { if (userPartitionInfo?.selectedPartitionIndex >= 0) { @@ -81,7 +81,6 @@ const ConfigureModal = ({ const initialValues = { releaseDate: sectionStartDate, isVisibleToStaffOnly: visibilityState === VisibilityTypes.STAFF_ONLY, - saveButtonDisabled: true, graderType: format == null ? 'notgraded' : format, dueDate: due == null ? '' : due, isTimeLimited, @@ -132,6 +131,10 @@ const ConfigureModal = ({ const isSubsection = category === COURSE_BLOCK_NAMES.sequential.id; + const dialogTitle = isXBlockComponent + ? intl.formatMessage(messages.componentTitle, { title: displayName }) + : intl.formatMessage(messages.title, { title: displayName }); + const handleSave = (data) => { const groupAccess = {}; switch (category) { @@ -159,6 +162,7 @@ const ConfigureModal = ({ ); break; case COURSE_BLOCK_NAMES.vertical.id: + case COURSE_BLOCK_NAMES.component.id: // groupAccess should be {partitionId: [group1, group2]} or {} if selectedPartitionIndex === -1 if (data.selectedPartitionIndex >= 0) { const partitionId = userPartitionInfo.selectablePartitions[data.selectedPartitionIndex].id; @@ -232,8 +236,10 @@ const ConfigureModal = ({ ); case COURSE_BLOCK_NAMES.vertical.id: + case COURSE_BLOCK_NAMES.component.id: return ( - {intl.formatMessage(messages.title, { title: displayName })} + {dialogTitle} {({ - values, handleSubmit, dirty, isValid, setFieldValue, + values, handleSubmit, setFieldValue, }) => ( <> @@ -281,7 +287,10 @@ const ConfigureModal = ({ {intl.formatMessage(messages.cancelButton)} - @@ -294,10 +303,63 @@ const ConfigureModal = ({ ); }; +ConfigureModal.defaultProps = { + isXBlockComponent: false, + enableProctoredExams: false, +}; + ConfigureModal.propTypes = { isOpen: PropTypes.bool.isRequired, onClose: PropTypes.func.isRequired, onConfigureSubmit: PropTypes.func.isRequired, + enableProctoredExams: PropTypes.bool, + currentItemData: PropTypes.shape({ + displayName: PropTypes.string, + start: PropTypes.string, + visibilityState: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + due: PropTypes.string, + isTimeLimited: PropTypes.bool, + defaultTimeLimitMinutes: PropTypes.number, + hideAfterDue: PropTypes.bool, + showCorrectness: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + courseGraders: PropTypes.arrayOf(PropTypes.string), + category: PropTypes.string, + format: PropTypes.string, + userPartitionInfo: PropTypes.shape({ + selectablePartitions: PropTypes.arrayOf(PropTypes.shape({ + groups: PropTypes.arrayOf(PropTypes.shape({ + deleted: PropTypes.bool, + id: PropTypes.number, + name: PropTypes.string, + selected: PropTypes.bool, + })), + id: PropTypes.number, + name: PropTypes.string, + scheme: PropTypes.string, + })), + selectedPartitionIndex: PropTypes.number, + selectedGroupsLabel: PropTypes.string, + }), + ancestorHasStaffLock: PropTypes.bool, + isPrereq: PropTypes.bool, + prereqs: PropTypes.arrayOf({ + blockDisplayName: PropTypes.string, + blockUsageKey: PropTypes.string, + }), + prereq: PropTypes.number, + prereqMinScore: PropTypes.number, + prereqMinCompletion: PropTypes.number, + releasedToStudents: PropTypes.bool, + wasExamEverLinkedWithExternal: PropTypes.bool, + isProctoredExam: PropTypes.bool, + isOnboardingExam: PropTypes.bool, + isPracticeExam: PropTypes.bool, + examReviewRules: PropTypes.string, + supportsOnboarding: PropTypes.bool, + showReviewRules: PropTypes.bool, + onlineProctoringRules: PropTypes.string, + }).isRequired, + isXBlockComponent: PropTypes.bool, }; export default ConfigureModal; diff --git a/src/course-outline/configure-modal/ConfigureModal.scss b/src/generic/configure-modal/ConfigureModal.scss similarity index 100% rename from src/course-outline/configure-modal/ConfigureModal.scss rename to src/generic/configure-modal/ConfigureModal.scss diff --git a/src/course-outline/configure-modal/ConfigureModal.test.jsx b/src/generic/configure-modal/ConfigureModal.test.jsx similarity index 56% rename from src/course-outline/configure-modal/ConfigureModal.test.jsx rename to src/generic/configure-modal/ConfigureModal.test.jsx index 9756f32467..3c4d699446 100644 --- a/src/course-outline/configure-modal/ConfigureModal.test.jsx +++ b/src/generic/configure-modal/ConfigureModal.test.jsx @@ -1,7 +1,6 @@ -import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { useSelector } from 'react-redux'; import { initializeMockApp } from '@edx/frontend-platform'; import MockAdapter from 'axios-mock-adapter'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; @@ -9,6 +8,12 @@ import { AppProvider } from '@edx/frontend-platform/react'; import initializeStore from '../../store'; import ConfigureModal from './ConfigureModal'; +import { + currentSectionMock, + currentSubsectionMock, + currentUnitMock, + currentXBlockMock, +} from './__mocks__'; import messages from './messages'; // eslint-disable-next-line no-unused-vars @@ -28,79 +33,6 @@ jest.mock('react-router-dom', () => ({ }), })); -const currentSectionMock = { - displayName: 'Section1', - category: 'chapter', - start: '2025-08-10T10:00:00Z', - visibilityState: true, - format: 'Not Graded', - childInfo: { - displayName: 'Subsection', - children: [ - { - displayName: 'Subsection 1', - id: 1, - category: 'sequential', - due: '', - start: '2025-08-10T10:00:00Z', - visibilityState: true, - defaultTimeLimitMinutes: null, - hideAfterDue: false, - showCorrectness: false, - format: 'Homework', - courseGraders: ['Homework', 'Exam'], - childInfo: { - displayName: 'Unit', - children: [ - { - id: 11, - displayName: 'Subsection_1 Unit 1', - }, - ], - }, - }, - { - displayName: 'Subsection 2', - id: 2, - category: 'sequential', - due: '', - start: '2025-08-10T10:00:00Z', - visibilityState: true, - defaultTimeLimitMinutes: null, - hideAfterDue: false, - showCorrectness: false, - format: 'Homework', - courseGraders: ['Homework', 'Exam'], - childInfo: { - displayName: 'Unit', - children: [ - { - id: 21, - displayName: 'Subsection_2 Unit 1', - }, - ], - }, - }, - { - displayName: 'Subsection 3', - id: 3, - category: 'sequential', - due: '', - start: '2025-08-10T10:00:00Z', - visibilityState: true, - defaultTimeLimitMinutes: null, - hideAfterDue: false, - showCorrectness: false, - format: 'Homework', - courseGraders: ['Homework', 'Exam'], - childInfo: { - children: [], - }, - }, - ], - }, -}; - const onCloseMock = jest.fn(); const onConfigureSubmitMock = jest.fn(); @@ -111,6 +43,7 @@ const renderComponent = () => render( isOpen onClose={onCloseMock} onConfigureSubmit={onConfigureSubmitMock} + currentItemData={currentSectionMock} /> , , @@ -129,12 +62,11 @@ describe(' for Section', () => { store = initializeStore(); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - useSelector.mockReturnValue(currentSectionMock); }); it('renders ConfigureModal component correctly', () => { const { getByText, getByRole } = renderComponent(); - expect(getByText(`${currentSectionMock.displayName} Settings`)).toBeInTheDocument(); + expect(getByText(`${currentSectionMock.displayName} settings`)).toBeInTheDocument(); expect(getByText(messages.basicTabTitle.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.visibilityTabTitle.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.releaseDate.defaultMessage)).toBeInTheDocument(); @@ -147,55 +79,12 @@ describe(' for Section', () => { const { getByRole, getByText } = renderComponent(); const visibilityTab = getByRole('tab', { name: messages.visibilityTabTitle.defaultMessage }); - fireEvent.click(visibilityTab); - expect(getByText('Section Visibility')).toBeInTheDocument(); + userEvent.click(visibilityTab); + expect(getByText('Section visibility')).toBeInTheDocument(); expect(getByText(messages.hideFromLearners.defaultMessage)).toBeInTheDocument(); }); - - it('disables the Save button and enables it if there is a change', () => { - const { getByRole, getByPlaceholderText, getByTestId } = renderComponent(); - - const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage }); - expect(saveButton).toBeDisabled(); - - const input = getByPlaceholderText('MM/DD/YYYY'); - fireEvent.change(input, { target: { value: '12/15/2023' } }); - - const visibilityTab = getByRole('tab', { name: messages.visibilityTabTitle.defaultMessage }); - fireEvent.click(visibilityTab); - const checkbox = getByTestId('visibility-checkbox'); - fireEvent.click(checkbox); - expect(saveButton).not.toBeDisabled(); - }); }); -const currentSubsectionMock = { - displayName: 'Subsection 1', - id: 1, - category: 'sequential', - due: '', - start: '2025-08-10T10:00:00Z', - visibilityState: true, - defaultTimeLimitMinutes: null, - hideAfterDue: false, - showCorrectness: false, - format: 'Homework', - courseGraders: ['Homework', 'Exam'], - childInfo: { - displayName: 'Unit', - children: [ - { - id: 11, - displayName: 'Subsection_1 Unit 1', - }, - { - id: 12, - displayName: 'Subsection_1 Unit 2', - }, - ], - }, -}; - const renderSubsectionComponent = () => render( @@ -203,6 +92,7 @@ const renderSubsectionComponent = () => render( isOpen onClose={onCloseMock} onConfigureSubmit={onConfigureSubmitMock} + currentItemData={currentSubsectionMock} /> , , @@ -221,12 +111,11 @@ describe(' for Subsection', () => { store = initializeStore(); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - useSelector.mockReturnValue(currentSubsectionMock); }); it('renders subsection ConfigureModal component correctly', () => { const { getByText, getByRole } = renderSubsectionComponent(); - expect(getByText(`${currentSubsectionMock.displayName} Settings`)).toBeInTheDocument(); + expect(getByText(`${currentSubsectionMock.displayName} settings`)).toBeInTheDocument(); expect(getByText(messages.basicTabTitle.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.visibilityTabTitle.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.advancedTabTitle.defaultMessage)).toBeInTheDocument(); @@ -244,8 +133,8 @@ describe(' for Subsection', () => { const { getByRole, getByText } = renderSubsectionComponent(); const visibilityTab = getByRole('tab', { name: messages.visibilityTabTitle.defaultMessage }); - fireEvent.click(visibilityTab); - expect(getByText('Subsection Visibility')).toBeInTheDocument(); + userEvent.click(visibilityTab); + expect(getByText('Subsection visibility')).toBeInTheDocument(); expect(getByText(messages.showEntireSubsection.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.showEntireSubsectionDescription.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.hideContentAfterDue.defaultMessage)).toBeInTheDocument(); @@ -265,82 +154,23 @@ describe(' for Subsection', () => { const { getByRole, getByText } = renderSubsectionComponent(); const advancedTab = getByRole('tab', { name: messages.advancedTabTitle.defaultMessage }); - fireEvent.click(advancedTab); + userEvent.click(advancedTab); expect(getByText(messages.setSpecialExam.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.none.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.timed.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.timedDescription.defaultMessage)).toBeInTheDocument(); }); - - it('disables the Save button and enables it if there is a change', () => { - const { getByRole, getByTestId } = renderSubsectionComponent(); - - const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage }); - expect(saveButton).toBeDisabled(); - - const input = getByTestId('grader-type-select'); - fireEvent.change(input, { target: { value: 'Exam' } }); - expect(saveButton).not.toBeDisabled(); - }); }); -const currentUnitMock = { - displayName: 'Unit 1', - id: 1, - category: 'vertical', - due: '', - start: '2025-08-10T10:00:00Z', - visibilityState: true, - defaultTimeLimitMinutes: null, - hideAfterDue: false, - showCorrectness: false, - userPartitionInfo: { - selectablePartitions: [ - { - id: 50, - name: 'Enrollment Track Groups', - scheme: 'enrollment_track', - groups: [ - { - id: 6, - name: 'Honor', - selected: false, - deleted: false, - }, - { - id: 2, - name: 'Verified', - selected: false, - deleted: false, - }, - ], - }, - { - id: 1508065533, - name: 'Content Groups', - scheme: 'cohort', - groups: [ - { - id: 1224170703, - name: 'Content Group 1', - selected: false, - deleted: false, - }, - ], - }, - ], - selectedPartitionIndex: -1, - selectedGroupsLabel: '', - }, -}; - -const renderUnitComponent = () => render( +const renderUnitComponent = (props) => render( , , @@ -359,14 +189,13 @@ describe(' for Unit', () => { store = initializeStore(); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - useSelector.mockReturnValue(currentUnitMock); }); it('renders unit ConfigureModal component correctly', () => { const { getByText, queryByText, getByRole, getByTestId, } = renderUnitComponent(); - expect(getByText(`${currentUnitMock.displayName} Settings`)).toBeInTheDocument(); + expect(getByText(`${currentUnitMock.displayName} settings`)).toBeInTheDocument(); expect(getByText(messages.unitVisibility.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.hideFromLearners.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.restrictAccessTo.defaultMessage)).toBeInTheDocument(); @@ -375,8 +204,8 @@ describe(' for Unit', () => { expect(queryByText(messages.unitSelectGroup.defaultMessage)).not.toBeInTheDocument(); const input = getByTestId('group-type-select'); - [0, 1].forEach(groupeTypeIndex => { - fireEvent.change(input, { target: { value: groupeTypeIndex } }); + ['0', '1'].forEach(groupeTypeIndex => { + userEvent.selectOptions(input, groupeTypeIndex); expect(getByText(messages.unitSelectGroup.defaultMessage)).toBeInTheDocument(); currentUnitMock @@ -388,32 +217,62 @@ describe(' for Unit', () => { expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument(); expect(getByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument(); }); +}); - it('disables the Save button and enables it if there is a change', () => { - useSelector.mockReturnValue( - { - ...currentUnitMock, - userPartitionInfo: { - ...currentUnitMock.userPartitionInfo, - selectedPartitionIndex: 0, - }, +const renderXBlockComponent = (props) => render( + + + + , + , +); + +describe(' for XBlock', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], }, - ); - const { getByRole, getByTestId } = renderUnitComponent(); + }); - const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage }); - expect(saveButton).toBeDisabled(); + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + it('renders unit ConfigureModal component correctly', () => { + const { + getByText, queryByText, getByRole, getByTestId, + } = renderXBlockComponent(); + expect(getByText(`Editing access for: ${currentUnitMock.displayName}`)).toBeInTheDocument(); + expect(queryByText(messages.unitVisibility.defaultMessage)).not.toBeInTheDocument(); + expect(queryByText(messages.hideFromLearners.defaultMessage)).not.toBeInTheDocument(); + expect(getByText(messages.restrictAccessTo.defaultMessage)).toBeInTheDocument(); + expect(getByText(messages.unitSelectGroupType.defaultMessage)).toBeInTheDocument(); + + expect(queryByText(messages.unitSelectGroup.defaultMessage)).not.toBeInTheDocument(); const input = getByTestId('group-type-select'); - // unrestrict access - fireEvent.change(input, { target: { value: -1 } }); - expect(saveButton).not.toBeDisabled(); - fireEvent.change(input, { target: { value: 0 } }); - expect(saveButton).toBeDisabled(); + ['0', '1'].forEach(groupeTypeIndex => { + userEvent.selectOptions(input, groupeTypeIndex); + + expect(getByText(messages.unitSelectGroup.defaultMessage)).toBeInTheDocument(); + currentUnitMock + .userPartitionInfo + .selectablePartitions[groupeTypeIndex].groups + .forEach(g => expect(getByText(g.name)).toBeInTheDocument()); + }); - const checkbox = getByTestId('unit-visibility-checkbox'); - fireEvent.click(checkbox); - expect(saveButton).not.toBeDisabled(); + expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument(); }); }); diff --git a/src/course-outline/configure-modal/PrereqSettings.jsx b/src/generic/configure-modal/PrereqSettings.jsx similarity index 98% rename from src/course-outline/configure-modal/PrereqSettings.jsx rename to src/generic/configure-modal/PrereqSettings.jsx index b79ffbf34d..74c5f7148e 100644 --- a/src/course-outline/configure-modal/PrereqSettings.jsx +++ b/src/generic/configure-modal/PrereqSettings.jsx @@ -4,7 +4,7 @@ import { Form } from '@openedx/paragon'; import { FormattedMessage, injectIntl, useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; -import FormikControl from '../../generic/FormikControl'; +import FormikControl from '../FormikControl'; const PrereqSettings = ({ values, diff --git a/src/course-outline/configure-modal/UnitTab.jsx b/src/generic/configure-modal/UnitTab.jsx similarity index 70% rename from src/course-outline/configure-modal/UnitTab.jsx rename to src/generic/configure-modal/UnitTab.jsx index ec838711da..2c38ab17d0 100644 --- a/src/course-outline/configure-modal/UnitTab.jsx +++ b/src/generic/configure-modal/UnitTab.jsx @@ -5,10 +5,12 @@ import { FormattedMessage, injectIntl, useIntl, } from '@edx/frontend-platform/i18n'; import { Field } from 'formik'; +import classNames from 'classnames'; import messages from './messages'; const UnitTab = ({ + isXBlockComponent, values, setFieldValue, showWarning, @@ -18,6 +20,7 @@ const UnitTab = ({ const { isVisibleToStaffOnly, selectedPartitionIndex, + selectedGroups, } = values; const handleChange = (e) => { @@ -26,21 +29,32 @@ const UnitTab = ({ const handleSelect = (e) => { setFieldValue('selectedPartitionIndex', parseInt(e.target.value, 10)); + setFieldValue('selectedGroups', []); + }; + + const checkIsDeletedGroup = (group) => { + const isGroupSelected = selectedGroups.includes(group.id.toString()); + + return group.deleted && isGroupSelected; }; return ( <> -

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

+
+ + + + {showWarning && ( + + + + )} +
+ )} -
@@ -89,9 +103,19 @@ const UnitTab = ({ value={`${group.id}`} name="selectedGroups" /> - - {group.name} - +
+ + {group.name} + + {group.deleted && ( + + {intl.formatMessage(messages.unitSelectDeletedGroupErrorMessage)} + + )} +
))}
@@ -103,13 +127,21 @@ const UnitTab = ({ ); }; +UnitTab.defaultProps = { + isXBlockComponent: false, +}; + UnitTab.propTypes = { + isXBlockComponent: PropTypes.bool, values: PropTypes.shape({ isVisibleToStaffOnly: PropTypes.bool.isRequired, selectedPartitionIndex: PropTypes.oneOfType([ PropTypes.string, PropTypes.number, ]).isRequired, + selectedGroups: PropTypes.oneOfType([ + PropTypes.string, + ]), }).isRequired, setFieldValue: PropTypes.func.isRequired, showWarning: PropTypes.bool.isRequired, diff --git a/src/course-outline/configure-modal/VisibilityTab.jsx b/src/generic/configure-modal/VisibilityTab.jsx similarity index 98% rename from src/course-outline/configure-modal/VisibilityTab.jsx rename to src/generic/configure-modal/VisibilityTab.jsx index 44ee964619..c6ce99da4d 100644 --- a/src/course-outline/configure-modal/VisibilityTab.jsx +++ b/src/generic/configure-modal/VisibilityTab.jsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import { Alert, Form } from '@openedx/paragon'; import { FormattedMessage, injectIntl, useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; -import { COURSE_BLOCK_NAMES } from '../constants'; +import { COURSE_BLOCK_NAMES } from '../../constants'; const VisibilityTab = ({ values, diff --git a/src/generic/configure-modal/__mocks__/index.js b/src/generic/configure-modal/__mocks__/index.js new file mode 100644 index 0000000000..8e69d242d9 --- /dev/null +++ b/src/generic/configure-modal/__mocks__/index.js @@ -0,0 +1,199 @@ +export const currentSectionMock = { + displayName: 'Section1', + category: 'chapter', + start: '2025-08-10T10:00:00Z', + visibilityState: true, + format: 'Not Graded', + childInfo: { + displayName: 'Subsection', + children: [ + { + displayName: 'Subsection 1', + id: 1, + category: 'sequential', + due: '', + start: '2025-08-10T10:00:00Z', + visibilityState: true, + defaultTimeLimitMinutes: null, + hideAfterDue: false, + showCorrectness: false, + format: 'Homework', + courseGraders: ['Homework', 'Exam'], + childInfo: { + displayName: 'Unit', + children: [ + { + id: 11, + displayName: 'Subsection_1 Unit 1', + }, + ], + }, + }, + { + displayName: 'Subsection 2', + id: 2, + category: 'sequential', + due: '', + start: '2025-08-10T10:00:00Z', + visibilityState: true, + defaultTimeLimitMinutes: null, + hideAfterDue: false, + showCorrectness: false, + format: 'Homework', + courseGraders: ['Homework', 'Exam'], + childInfo: { + displayName: 'Unit', + children: [ + { + id: 21, + displayName: 'Subsection_2 Unit 1', + }, + ], + }, + }, + { + displayName: 'Subsection 3', + id: 3, + category: 'sequential', + due: '', + start: '2025-08-10T10:00:00Z', + visibilityState: true, + defaultTimeLimitMinutes: null, + hideAfterDue: false, + showCorrectness: false, + format: 'Homework', + courseGraders: ['Homework', 'Exam'], + childInfo: { + children: [], + }, + }, + ], + }, +}; + +export const currentSubsectionMock = { + displayName: 'Subsection 1', + id: 1, + category: 'sequential', + due: '', + start: '2025-08-10T10:00:00Z', + visibilityState: true, + defaultTimeLimitMinutes: null, + hideAfterDue: false, + showCorrectness: false, + format: 'Homework', + courseGraders: ['Homework', 'Exam'], + childInfo: { + displayName: 'Unit', + children: [ + { + id: 11, + displayName: 'Subsection_1 Unit 1', + }, + { + id: 12, + displayName: 'Subsection_1 Unit 2', + }, + ], + }, +}; + +export const currentUnitMock = { + displayName: 'Unit 1', + id: 1, + category: 'vertical', + due: '', + start: '2025-08-10T10:00:00Z', + visibilityState: true, + defaultTimeLimitMinutes: null, + hideAfterDue: false, + showCorrectness: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 6, + name: 'Honor', + selected: false, + deleted: false, + }, + { + id: 2, + name: 'Verified', + selected: false, + deleted: false, + }, + ], + }, + { + id: 1508065533, + name: 'Content Groups', + scheme: 'cohort', + groups: [ + { + id: 1224170703, + name: 'Content Group 1', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, +}; + +export const currentXBlockMock = { + displayName: 'Unit 1', + id: 1, + category: 'component', + due: '', + start: '2025-08-10T10:00:00Z', + visibilityState: true, + defaultTimeLimitMinutes: null, + hideAfterDue: false, + showCorrectness: false, + userPartitionInfo: { + selectablePartitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 6, + name: 'Honor', + selected: false, + deleted: false, + }, + { + id: 2, + name: 'Verified', + selected: false, + deleted: false, + }, + ], + }, + { + id: 1508065533, + name: 'Content Groups', + scheme: 'cohort', + groups: [ + { + id: 1224170703, + name: 'Content Group 1', + selected: false, + deleted: false, + }, + ], + }, + ], + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, +}; diff --git a/src/course-outline/configure-modal/messages.js b/src/generic/configure-modal/messages.js similarity index 93% rename from src/course-outline/configure-modal/messages.js rename to src/generic/configure-modal/messages.js index 316cbc0fb0..d27f943dc6 100644 --- a/src/course-outline/configure-modal/messages.js +++ b/src/generic/configure-modal/messages.js @@ -3,7 +3,12 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ title: { id: 'course-authoring.course-outline.configure-modal.title', - defaultMessage: '{title} Settings', + defaultMessage: '{title} settings', + }, + componentTitle: { + id: 'course-authoring.course-outline.configure-modal.component.title', + defaultMessage: 'Editing access for: {title}', + description: 'The visibility modal title for unit', }, basicTabTitle: { id: 'course-authoring.course-outline.configure-modal.basic-tab.title', @@ -15,15 +20,15 @@ const messages = defineMessages({ }, releaseDateAndTime: { id: 'course-authoring.course-outline.configure-modal.basic-tab.release-date-and-time', - defaultMessage: 'Release Date and Time', + defaultMessage: 'Release date and time', }, releaseDate: { id: 'course-authoring.course-outline.configure-modal.basic-tab.release-date', - defaultMessage: 'Release Date:', + defaultMessage: 'Release date:', }, releaseTimeUTC: { id: 'course-authoring.course-outline.configure-modal.basic-tab.release-time-UTC', - defaultMessage: 'Release Time in UTC:', + defaultMessage: 'Release time in UTC:', }, visibilityTabTitle: { id: 'course-authoring.course-outline.configure-modal.visibility-tab.title', @@ -31,11 +36,11 @@ const messages = defineMessages({ }, visibilitySectionTitle: { id: 'course-authoring.course-outline.configure-modal.visibility-tab.section-visibility', - defaultMessage: '{visibilityTitle} Visibility', + defaultMessage: '{visibilityTitle} visibility', }, unitVisibility: { id: 'course-authoring.course-outline.configure-modal.visibility-tab.unit-visibility', - defaultMessage: 'Unit Visibility', + defaultMessage: 'Unit visibility', }, hideFromLearners: { id: 'course-authoring.course-outline.configure-modal.visibility.hide-from-learners', @@ -65,6 +70,11 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.configure-modal.unit-tab.unit-select-group-type', defaultMessage: 'Select a group type', }, + unitSelectDeletedGroupErrorMessage: { + id: 'course-authoring.course-outline.configure-modal.unit-tab.unit-select-group-deleted-error-message', + defaultMessage: 'This group no longer exists. Choose another group or remove the access restriction.', + description: 'The alert text of no longer available group', + }, unitAllLearnersAndStaff: { id: 'course-authoring.course-outline.configure-modal.unit-tab.unit-all-learners-staff', defaultMessage: 'All Learners and Staff', @@ -87,15 +97,15 @@ const messages = defineMessages({ }, dueDate: { id: 'course-authoring.course-outline.configure-modal.basic-tab.due-date', - defaultMessage: 'Due Date:', + defaultMessage: 'Due date:', }, dueTimeUTC: { id: 'course-authoring.course-outline.configure-modal.basic-tab.due-time-UTC', - defaultMessage: 'Due Time in UTC:', + defaultMessage: 'Due time in UTC:', }, subsectionVisibility: { id: 'course-authoring.course-outline.configure-modal.visibility-tab.subsection-visibility', - defaultMessage: 'Subsection Visibility', + defaultMessage: 'Subsection visibility', }, showEntireSubsection: { id: 'course-authoring.course-outline.configure-modal.visibility-tab.show-entire-subsection', @@ -151,7 +161,7 @@ const messages = defineMessages({ }, setSpecialExam: { id: 'course-authoring.course-outline.configure-modal.advanced-tab.set-special-exam', - defaultMessage: 'Set as a Special Exam', + defaultMessage: 'Set as a special exam', }, none: { id: 'course-authoring.course-outline.configure-modal.advanced-tab.none', @@ -195,7 +205,7 @@ const messages = defineMessages({ }, timeAllotted: { id: 'course-authoring.course-outline.configure-modal.advanced-tab.time-allotted', - defaultMessage: 'Time Allotted (HH:MM):', + defaultMessage: 'Time allotted (HH:MM):', }, timeLimitDescription: { id: 'course-authoring.course-outline.configure-modal.advanced-tab.time-limit-description', diff --git a/src/generic/styles.scss b/src/generic/styles.scss index 71166f7f1b..0ef6a6202e 100644 --- a/src/generic/styles.scss +++ b/src/generic/styles.scss @@ -8,3 +8,4 @@ @import "./course-stepper/CouseStepper"; @import "./tag-count/TagCount"; @import "./modal-dropzone/ModalDropzone"; +@import "./configure-modal/ConfigureModal"; From 7f668a6ca461116fd9ac2abb00113051420b925d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Tue, 23 Apr 2024 06:22:17 -0300 Subject: [PATCH 5/8] refactor: remove old taxonomy page route (#954) --- src/index.jsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 */} - } /> }> } /> From 907ce50071068719c6458bfc85603ab995e010e6 Mon Sep 17 00:00:00 2001 From: ruzniaievdm Date: Tue, 23 Apr 2024 18:53:49 +0300 Subject: [PATCH 6/8] feat: [FC-0044] group configurations MFE page (#929) * feat: group configurations - index page * feat: [AXIMST-63] Index group configurations page * fix: resolve discussions * fix: resolve second round discussions * feat: group configurations - content group actions * feat: [AXIMST-75, AXIMST-69, AXIMST-81] Content group actions * fix: resolve conversations * feat: group configurations - sidebar * feat: [AXIMST-87] group-configuration page sidebar * refactor: [AXIMST-87] add changes after review * refactor: [AXIMST-87] add changes after review * refactor: [AXIMST-87] add changes ater review --------- Co-authored-by: Kyrylo Hudym-Levkovych * fix: group configurations - the page reloads after the user saves changes * feat: group configurations - experiment groups * feat: [AXIMST-93, 99, 105] Group configuration - Experiment Groups * fix: [AXIMST-518, 537] Group configuration - resolve bugs * fix: review discussions * fix: revert classname case * fix: group configurations - resolve discussions fix: [AXIMST-714] icon is aligned with text (#210) * fix: add hook tests * fix: add thunk tests * fix: add slice tests * chore: group configurations - messages * fix: group configurations - remove delete in edit mode --------- Co-authored-by: Kyr <40792129+khudym@users.noreply.github.com> Co-authored-by: Kyrylo Hudym-Levkovych Co-authored-by: monteri --- src/CourseAuthoringRoutes.jsx | 5 + src/generic/prompt-if-dirty/PromptIfDirty.jsx | 24 ++ .../prompt-if-dirty/PromptIfDirty.test.jsx | 72 ++++++ .../GroupConfigurations.scss | 130 ++++++++++ .../GroupConfigurations.test.jsx | 106 ++++++++ .../__mocks__/contentGroupsMock.js | 44 ++++ .../__mocks__/enrollmentTrackGroupsMock.js | 32 +++ .../experimentGroupConfigurationsMock.js | 79 ++++++ .../groupConfigurationResponseMock.js | 149 +++++++++++ src/group-configurations/__mocks__/index.js | 4 + .../common/TitleButton.jsx | 100 ++++++++ src/group-configurations/common/UsageList.jsx | 73 ++++++ .../common/UsageList.test.jsx | 34 +++ src/group-configurations/common/index.js | 2 + src/group-configurations/common/messages.js | 21 ++ src/group-configurations/constants.js | 34 +++ .../ContentGroupCard.jsx | 200 +++++++++++++++ .../ContentGroupCard.test.jsx | 93 +++++++ .../ContentGroupForm.jsx | 123 +++++++++ .../ContentGroupForm.test.jsx | 176 +++++++++++++ .../ContentGroupsSection.test.jsx | 64 +++++ .../content-groups-section/index.jsx | 95 +++++++ .../content-groups-section/messages.js | 86 +++++++ .../content-groups-section/utils.js | 9 + src/group-configurations/data/api.js | 115 +++++++++ src/group-configurations/data/api.test.js | 149 +++++++++++ src/group-configurations/data/selectors.js | 3 + src/group-configurations/data/slice.js | 78 ++++++ src/group-configurations/data/slice.test.js | 78 ++++++ src/group-configurations/data/thunk.js | 148 +++++++++++ src/group-configurations/data/thunk.test.js | 95 +++++++ .../empty-placeholder/EmptyPlaceholder.scss | 10 + .../EmptyPlaceholder.test.jsx | 22 ++ .../empty-placeholder/index.jsx | 42 ++++ .../empty-placeholder/messages.js | 26 ++ .../EnrollmentTrackGroupsSection.test.jsx | 24 ++ .../enrollment-track-groups-section/index.jsx | 23 ++ .../ExperimentCard.jsx | 221 ++++++++++++++++ .../ExperimentCard.test.jsx | 123 +++++++++ .../ExperimentCardGroup.jsx | 41 +++ .../ExperimentConfigurationsSection.test.jsx | 45 ++++ .../ExperimentForm.jsx | 164 ++++++++++++ .../ExperimentForm.test.jsx | 236 ++++++++++++++++++ .../ExperimentFormGroups.jsx | 124 +++++++++ .../constants.js | 18 ++ .../index.jsx | 115 +++++++++ .../messages.js | 146 +++++++++++ .../utils.js | 73 ++++++ .../utils.test.js | 174 +++++++++++++ .../validation.js | 45 ++++ .../GroupConfigurationSidebar.test.jsx | 104 ++++++++ .../group-configuration-sidebar/index.jsx | 59 +++++ .../group-configuration-sidebar/messages.js | 81 ++++++ .../group-configuration-sidebar/utils.jsx | 57 +++++ src/group-configurations/hooks.jsx | 97 +++++++ src/group-configurations/hooks.test.jsx | 109 ++++++++ src/group-configurations/index.jsx | 123 +++++++++ src/group-configurations/messages.js | 31 +++ src/group-configurations/utils.js | 53 ++++ src/hooks.js | 12 +- src/index.scss | 1 + src/store.js | 2 + 62 files changed, 4818 insertions(+), 4 deletions(-) create mode 100644 src/generic/prompt-if-dirty/PromptIfDirty.jsx create mode 100644 src/generic/prompt-if-dirty/PromptIfDirty.test.jsx create mode 100644 src/group-configurations/GroupConfigurations.scss create mode 100644 src/group-configurations/GroupConfigurations.test.jsx create mode 100644 src/group-configurations/__mocks__/contentGroupsMock.js create mode 100644 src/group-configurations/__mocks__/enrollmentTrackGroupsMock.js create mode 100644 src/group-configurations/__mocks__/experimentGroupConfigurationsMock.js create mode 100644 src/group-configurations/__mocks__/groupConfigurationResponseMock.js create mode 100644 src/group-configurations/__mocks__/index.js create mode 100644 src/group-configurations/common/TitleButton.jsx create mode 100644 src/group-configurations/common/UsageList.jsx create mode 100644 src/group-configurations/common/UsageList.test.jsx create mode 100644 src/group-configurations/common/index.js create mode 100644 src/group-configurations/common/messages.js create mode 100644 src/group-configurations/constants.js create mode 100644 src/group-configurations/content-groups-section/ContentGroupCard.jsx create mode 100644 src/group-configurations/content-groups-section/ContentGroupCard.test.jsx create mode 100644 src/group-configurations/content-groups-section/ContentGroupForm.jsx create mode 100644 src/group-configurations/content-groups-section/ContentGroupForm.test.jsx create mode 100644 src/group-configurations/content-groups-section/ContentGroupsSection.test.jsx create mode 100644 src/group-configurations/content-groups-section/index.jsx create mode 100644 src/group-configurations/content-groups-section/messages.js create mode 100644 src/group-configurations/content-groups-section/utils.js create mode 100644 src/group-configurations/data/api.js create mode 100644 src/group-configurations/data/api.test.js create mode 100644 src/group-configurations/data/selectors.js create mode 100644 src/group-configurations/data/slice.js create mode 100644 src/group-configurations/data/slice.test.js create mode 100644 src/group-configurations/data/thunk.js create mode 100644 src/group-configurations/data/thunk.test.js create mode 100644 src/group-configurations/empty-placeholder/EmptyPlaceholder.scss create mode 100644 src/group-configurations/empty-placeholder/EmptyPlaceholder.test.jsx create mode 100644 src/group-configurations/empty-placeholder/index.jsx create mode 100644 src/group-configurations/empty-placeholder/messages.js create mode 100644 src/group-configurations/enrollment-track-groups-section/EnrollmentTrackGroupsSection.test.jsx create mode 100644 src/group-configurations/enrollment-track-groups-section/index.jsx create mode 100644 src/group-configurations/experiment-configurations-section/ExperimentCard.jsx create mode 100644 src/group-configurations/experiment-configurations-section/ExperimentCard.test.jsx create mode 100644 src/group-configurations/experiment-configurations-section/ExperimentCardGroup.jsx create mode 100644 src/group-configurations/experiment-configurations-section/ExperimentConfigurationsSection.test.jsx create mode 100644 src/group-configurations/experiment-configurations-section/ExperimentForm.jsx create mode 100644 src/group-configurations/experiment-configurations-section/ExperimentForm.test.jsx create mode 100644 src/group-configurations/experiment-configurations-section/ExperimentFormGroups.jsx create mode 100644 src/group-configurations/experiment-configurations-section/constants.js create mode 100644 src/group-configurations/experiment-configurations-section/index.jsx create mode 100644 src/group-configurations/experiment-configurations-section/messages.js create mode 100644 src/group-configurations/experiment-configurations-section/utils.js create mode 100644 src/group-configurations/experiment-configurations-section/utils.test.js create mode 100644 src/group-configurations/experiment-configurations-section/validation.js create mode 100644 src/group-configurations/group-configuration-sidebar/GroupConfigurationSidebar.test.jsx create mode 100644 src/group-configurations/group-configuration-sidebar/index.jsx create mode 100644 src/group-configurations/group-configuration-sidebar/messages.js create mode 100644 src/group-configurations/group-configuration-sidebar/utils.jsx create mode 100644 src/group-configurations/hooks.jsx create mode 100644 src/group-configurations/hooks.test.jsx create mode 100644 src/group-configurations/index.jsx create mode 100644 src/group-configurations/messages.js create mode 100644 src/group-configurations/utils.js 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/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/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.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/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, }); From 34104495c57d0ad59615098e68e25f7181529bb0 Mon Sep 17 00:00:00 2001 From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com> Date: Tue, 23 Apr 2024 16:05:13 -0400 Subject: [PATCH 7/8] fix: adding files count in toast (#960) * fix: adding files count in toast * fix: toast to use plural function --- src/files-and-videos/generic/FileInput.jsx | 2 +- src/files-and-videos/generic/messages.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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: { From c32462e21e09a03cf38392fecd3181310a8b89db Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 23 Apr 2024 20:45:17 -0700 Subject: [PATCH 8/8] feat: Allow filtering by multiple tags [FC-0040] (#945) As of #918 , the content search only allows filtering the results by one tag at a time, which is a limitation of Instantsearch. So with this change, usage of Instantsearch + instant-meilisearch has been replaced with direct usage of Meilisearch. Not only does this simplify the code and make our MFE bundle size smaller, but it allows us much more control over how the tags filtering works, so that we can implement searching by multiple tags. Trying to modify Instantsearch to do that was too difficult, given the complexity of its codebase. Related ticket: openedx/modular-learning#201 --- package-lock.json | 341 +--------------- package.json | 3 +- src/search-modal/ClearFiltersButton.jsx | 8 +- src/search-modal/EmptyStates.jsx | 23 +- src/search-modal/FilterByBlockType.jsx | 57 ++- src/search-modal/FilterByTags.jsx | 253 ++++++++---- src/search-modal/Highlight.jsx | 28 ++ src/search-modal/SearchEndpointLoader.jsx | 41 -- src/search-modal/SearchKeywordsField.jsx | 14 +- src/search-modal/SearchModal.jsx | 4 +- src/search-modal/SearchModal.scss | 30 +- src/search-modal/SearchModal.test.jsx | 10 +- src/search-modal/SearchResult.jsx | 105 ++--- src/search-modal/SearchResults.jsx | 50 +++ src/search-modal/SearchUI.jsx | 69 +--- src/search-modal/SearchUI.test.jsx | 136 +++++-- src/search-modal/Stats.jsx | 12 +- .../__mocks__/empty-search-result.json | 16 +- .../__mocks__/facet-search-level0.json | 13 + .../__mocks__/facet-search-level1.json | 8 + src/search-modal/__mocks__/facet-search.json | 12 + src/search-modal/__mocks__/search-result.json | 19 +- .../__mocks__/tags-keyword-search.json | 48 +++ src/search-modal/data/api.js | 366 +++++++++++++++++- src/search-modal/data/apiHooks.js | 186 ++++++++- src/search-modal/manager/SearchManager.js | 104 +++++ src/search-modal/messages.js | 50 ++- 27 files changed, 1272 insertions(+), 734 deletions(-) create mode 100644 src/search-modal/Highlight.jsx delete mode 100644 src/search-modal/SearchEndpointLoader.jsx create mode 100644 src/search-modal/SearchResults.jsx create mode 100644 src/search-modal/__mocks__/facet-search-level0.json create mode 100644 src/search-modal/__mocks__/facet-search-level1.json create mode 100644 src/search-modal/__mocks__/facet-search.json create mode 100644 src/search-modal/__mocks__/tags-keyword-search.json create mode 100644 src/search-modal/manager/SearchManager.js diff --git a/package-lock.json b/package-lock.json index e46b24c7a3..062af01e6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,16 +46,15 @@ "email-validator": "2.0.4", "file-saver": "^2.0.5", "formik": "2.2.6", - "instantsearch.css": "^8.1.0", "jszip": "^3.10.1", "lodash": "4.17.21", + "meilisearch": "^0.38.0", "moment": "2.29.4", "prop-types": "15.7.2", "react": "17.0.2", "react-datepicker": "^4.13.0", "react-dom": "17.0.2", "react-helmet": "^6.1.0", - "react-instantsearch": "^7.7.1", "react-redux": "7.2.9", "react-responsive": "9.0.2", "react-router": "6.16.0", @@ -110,159 +109,6 @@ "integrity": "sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ==", "dev": true }, - "node_modules/@algolia/cache-browser-local-storage": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.23.2.tgz", - "integrity": "sha512-PvRQdCmtiU22dw9ZcTJkrVKgNBVAxKgD0/cfiqyxhA5+PHzA2WDt6jOmZ9QASkeM2BpyzClJb/Wr1yt2/t78Kw==", - "peer": true, - "dependencies": { - "@algolia/cache-common": "4.23.2" - } - }, - "node_modules/@algolia/cache-common": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/cache-common/-/cache-common-4.23.2.tgz", - "integrity": "sha512-OUK/6mqr6CQWxzl/QY0/mwhlGvS6fMtvEPyn/7AHUx96NjqDA4X4+Ju7aXFQKh+m3jW9VPB0B9xvEQgyAnRPNw==", - "peer": true - }, - "node_modules/@algolia/cache-in-memory": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/cache-in-memory/-/cache-in-memory-4.23.2.tgz", - "integrity": "sha512-rfbi/SnhEa3MmlqQvgYz/9NNJ156NkU6xFxjbxBtLWnHbpj+qnlMoKd+amoiacHRITpajg6zYbLM9dnaD3Bczw==", - "peer": true, - "dependencies": { - "@algolia/cache-common": "4.23.2" - } - }, - "node_modules/@algolia/client-account": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/client-account/-/client-account-4.23.2.tgz", - "integrity": "sha512-VbrOCLIN/5I7iIdskSoSw3uOUPF516k4SjDD4Qz3BFwa3of7D9A0lzBMAvQEJJEPHWdVraBJlGgdJq/ttmquJQ==", - "peer": true, - "dependencies": { - "@algolia/client-common": "4.23.2", - "@algolia/client-search": "4.23.2", - "@algolia/transporter": "4.23.2" - } - }, - "node_modules/@algolia/client-analytics": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-4.23.2.tgz", - "integrity": "sha512-lLj7irsAztGhMoEx/SwKd1cwLY6Daf1Q5f2AOsZacpppSvuFvuBrmkzT7pap1OD/OePjLKxicJS8wNA0+zKtuw==", - "peer": true, - "dependencies": { - "@algolia/client-common": "4.23.2", - "@algolia/client-search": "4.23.2", - "@algolia/requester-common": "4.23.2", - "@algolia/transporter": "4.23.2" - } - }, - "node_modules/@algolia/client-common": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.23.2.tgz", - "integrity": "sha512-Q2K1FRJBern8kIfZ0EqPvUr3V29ICxCm/q42zInV+VJRjldAD9oTsMGwqUQ26GFMdFYmqkEfCbY4VGAiQhh22g==", - "peer": true, - "dependencies": { - "@algolia/requester-common": "4.23.2", - "@algolia/transporter": "4.23.2" - } - }, - "node_modules/@algolia/client-personalization": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-4.23.2.tgz", - "integrity": "sha512-vwPsgnCGhUcHhhQG5IM27z8q7dWrN9itjdvgA6uKf2e9r7vB+WXt4OocK0CeoYQt3OGEAExryzsB8DWqdMK5wg==", - "peer": true, - "dependencies": { - "@algolia/client-common": "4.23.2", - "@algolia/requester-common": "4.23.2", - "@algolia/transporter": "4.23.2" - } - }, - "node_modules/@algolia/client-search": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.23.2.tgz", - "integrity": "sha512-CxSB29OVGSE7l/iyoHvamMonzq7Ev8lnk/OkzleODZ1iBcCs3JC/XgTIKzN/4RSTrJ9QybsnlrN/bYCGufo7qw==", - "peer": true, - "dependencies": { - "@algolia/client-common": "4.23.2", - "@algolia/requester-common": "4.23.2", - "@algolia/transporter": "4.23.2" - } - }, - "node_modules/@algolia/events": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@algolia/events/-/events-4.0.1.tgz", - "integrity": "sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ==" - }, - "node_modules/@algolia/logger-common": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/logger-common/-/logger-common-4.23.2.tgz", - "integrity": "sha512-jGM49Q7626cXZ7qRAWXn0jDlzvoA1FvN4rKTi1g0hxKsTTSReyYk0i1ADWjChDPl3Q+nSDhJuosM2bBUAay7xw==", - "peer": true - }, - "node_modules/@algolia/logger-console": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/logger-console/-/logger-console-4.23.2.tgz", - "integrity": "sha512-oo+lnxxEmlhTBTFZ3fGz1O8PJ+G+8FiAoMY2Qo3Q4w23xocQev6KqDTA1JQAGPDxAewNA2VBwWOsVXeXFjrI/Q==", - "peer": true, - "dependencies": { - "@algolia/logger-common": "4.23.2" - } - }, - "node_modules/@algolia/recommend": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-4.23.2.tgz", - "integrity": "sha512-Q75CjnzRCDzgIlgWfPnkLtrfF4t82JCirhalXkSSwe/c1GH5pWh4xUyDOR3KTMo+YxxX3zTlrL/FjHmUJEWEcg==", - "peer": true, - "dependencies": { - "@algolia/cache-browser-local-storage": "4.23.2", - "@algolia/cache-common": "4.23.2", - "@algolia/cache-in-memory": "4.23.2", - "@algolia/client-common": "4.23.2", - "@algolia/client-search": "4.23.2", - "@algolia/logger-common": "4.23.2", - "@algolia/logger-console": "4.23.2", - "@algolia/requester-browser-xhr": "4.23.2", - "@algolia/requester-common": "4.23.2", - "@algolia/requester-node-http": "4.23.2", - "@algolia/transporter": "4.23.2" - } - }, - "node_modules/@algolia/requester-browser-xhr": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.23.2.tgz", - "integrity": "sha512-TO9wLlp8+rvW9LnIfyHsu8mNAMYrqNdQ0oLF6eTWFxXfxG3k8F/Bh7nFYGk2rFAYty4Fw4XUtrv/YjeNDtM5og==", - "peer": true, - "dependencies": { - "@algolia/requester-common": "4.23.2" - } - }, - "node_modules/@algolia/requester-common": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-common/-/requester-common-4.23.2.tgz", - "integrity": "sha512-3EfpBS0Hri0lGDB5H/BocLt7Vkop0bTTLVUBB844HH6tVycwShmsV6bDR7yXbQvFP1uNpgePRD3cdBCjeHmk6Q==", - "peer": true - }, - "node_modules/@algolia/requester-node-http": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-4.23.2.tgz", - "integrity": "sha512-SVzgkZM/malo+2SB0NWDXpnT7nO5IZwuDTaaH6SjLeOHcya1o56LSWXk+3F3rNLz2GVH+I/rpYKiqmHhSOjerw==", - "peer": true, - "dependencies": { - "@algolia/requester-common": "4.23.2" - } - }, - "node_modules/@algolia/transporter": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/@algolia/transporter/-/transporter-4.23.2.tgz", - "integrity": "sha512-GY3aGKBy+8AK4vZh8sfkatDciDVKad5rTY2S10Aefyjh7e7UGBP4zigf42qVXwU8VOPwi7l/L7OACGMOFcjB0Q==", - "peer": true, - "dependencies": { - "@algolia/cache-common": "4.23.2", - "@algolia/logger-common": "4.23.2", - "@algolia/requester-common": "4.23.2" - } - }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -5694,11 +5540,6 @@ "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz", "integrity": "sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow==" }, - "node_modules/@types/dom-speech-recognition": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/@types/dom-speech-recognition/-/dom-speech-recognition-0.0.1.tgz", - "integrity": "sha512-udCxb8DvjcDKfk1WTBzDsxFbLgYxmQGKrE/ricoMqHRNjSlSUCcamVTA5lIQqzY10mY5qCY0QDwBfFEwhfoDPw==" - }, "node_modules/@types/eslint": { "version": "8.56.7", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.7.tgz", @@ -5753,11 +5594,6 @@ "@types/node": "*" } }, - "node_modules/@types/google.maps": { - "version": "3.55.6", - "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.55.6.tgz", - "integrity": "sha512-RDtveRsejIi7KRnahz+PE1+Uo+6axr98Susjn/7DxNPPej/T0sMMJfnwm3NcQgvVDWvixWCMOn2Sfukq5UVF2g==" - }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -5766,11 +5602,6 @@ "@types/node": "*" } }, - "node_modules/@types/hogan.js": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/hogan.js/-/hogan.js-3.0.5.tgz", - "integrity": "sha512-/uRaY3HGPWyLqOyhgvW9Aa43BNnLZrNeQxl2p8wqId4UHMfPKolSB+U7BlZyO1ng7MkLnyEAItsBzCG0SDhqrA==" - }, "node_modules/@types/hoist-non-react-statics": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", @@ -6547,11 +6378,6 @@ "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", "deprecated": "Use your platform's native atob() and btoa() methods instead" }, - "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" - }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -6720,40 +6546,6 @@ "ajv": "^6.9.1" } }, - "node_modules/algoliasearch": { - "version": "4.23.2", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.23.2.tgz", - "integrity": "sha512-8aCl055IsokLuPU8BzLjwzXjb7ty9TPcUFFOk0pYOwsE5DMVhE3kwCMFtsCFKcnoPZK7oObm+H5mbnSO/9ioxQ==", - "peer": true, - "dependencies": { - "@algolia/cache-browser-local-storage": "4.23.2", - "@algolia/cache-common": "4.23.2", - "@algolia/cache-in-memory": "4.23.2", - "@algolia/client-account": "4.23.2", - "@algolia/client-analytics": "4.23.2", - "@algolia/client-common": "4.23.2", - "@algolia/client-personalization": "4.23.2", - "@algolia/client-search": "4.23.2", - "@algolia/logger-common": "4.23.2", - "@algolia/logger-console": "4.23.2", - "@algolia/recommend": "4.23.2", - "@algolia/requester-browser-xhr": "4.23.2", - "@algolia/requester-common": "4.23.2", - "@algolia/requester-node-http": "4.23.2", - "@algolia/transporter": "4.23.2" - } - }, - "node_modules/algoliasearch-helper": { - "version": "3.17.0", - "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.17.0.tgz", - "integrity": "sha512-R5422OiQjvjlK3VdpNQ/Qk7KsTIGeM5ACm8civGifOVWdRRV/3SgXuKmeNxe94Dz6fwj/IgpVmXbHutU4mHubg==", - "dependencies": { - "@algolia/events": "^4.0.1" - }, - "peerDependencies": { - "algoliasearch": ">= 3.1 < 6" - } - }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -12001,18 +11793,6 @@ "value-equal": "^1.0.1" } }, - "node_modules/hogan.js": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/hogan.js/-/hogan.js-3.0.2.tgz", - "integrity": "sha512-RqGs4wavGYJWE07t35JQccByczmNUXQT0E12ZYV1VKYu5UiAU9lsos/yBAcf840+zrUQQxgVduCR5/B8nNtibg==", - "dependencies": { - "mkdirp": "0.3.0", - "nopt": "1.0.10" - }, - "bin": { - "hulk": "bin/hulk" - } - }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -12037,11 +11817,6 @@ "wbuf": "^1.1.0" } }, - "node_modules/htm": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.1.tgz", - "integrity": "sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==" - }, "node_modules/html-encoding-sniffer": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", @@ -12661,52 +12436,6 @@ "node": ">=12.0.0" } }, - "node_modules/instantsearch-ui-components": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/instantsearch-ui-components/-/instantsearch-ui-components-0.4.0.tgz", - "integrity": "sha512-Isa9Ankm89e9PUXsUto6TxYzcQpXKlWZMsKLXc//dO4i9q5JS8s0Es+c+U65jRLK2j1DiVlNx/Z6HshRIZwA8w==", - "dependencies": { - "@babel/runtime": "^7.1.2" - } - }, - "node_modules/instantsearch.css": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/instantsearch.css/-/instantsearch.css-8.1.0.tgz", - "integrity": "sha512-rPhcAZ02bLwUn3iOXbldZW/yl+17guWoH3qWYZ8nQEwNBx5+wZ6Bv8mFqqK448+R2aU4nbFKIhmoTIPXI5Zobg==" - }, - "node_modules/instantsearch.js": { - "version": "4.66.1", - "resolved": "https://registry.npmjs.org/instantsearch.js/-/instantsearch.js-4.66.1.tgz", - "integrity": "sha512-RXFLrDSVHTBXeaGrS9Gqb6Vo1a6U0iCoDzNsJDn2kzIGjzP/SaFVLMdFW5ewAgCn9EUPmP2yImQv7mqgzmxe/g==", - "dependencies": { - "@algolia/events": "^4.0.1", - "@types/dom-speech-recognition": "^0.0.1", - "@types/google.maps": "^3.45.3", - "@types/hogan.js": "^3.0.0", - "@types/qs": "^6.5.3", - "algoliasearch-helper": "3.17.0", - "hogan.js": "^3.0.2", - "htm": "^3.0.0", - "instantsearch-ui-components": "0.4.0", - "preact": "^10.10.0", - "qs": "^6.5.1 < 6.10", - "search-insights": "^2.13.0" - }, - "peerDependencies": { - "algoliasearch": ">= 3.1 < 6" - } - }, - "node_modules/instantsearch.js/node_modules/qs": { - "version": "6.9.7", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.7.tgz", - "integrity": "sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw==", - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/internal-slot": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", @@ -15734,15 +15463,6 @@ "node": ">=0.10.0" } }, - "node_modules/mkdirp": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz", - "integrity": "sha512-OHsdUcVAQ6pOtg5JYWpCBo9W/GySVuwvP9hueRMW7UqshC0tbfzLv8wjySTPm3tfUZ/21CE9E1pJagOA91Pxew==", - "deprecated": "Legacy versions of mkdirp are no longer supported. Please update to mkdirp 1.x. (Note that the API surface has changed to use Promises in 1.x.)", - "engines": { - "node": "*" - } - }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -16070,20 +15790,6 @@ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==" }, - "node_modules/nopt": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", - "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==", - "dependencies": { - "abbrev": "1" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "*" - } - }, "node_modules/normalize-package-data": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", @@ -17577,15 +17283,6 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, - "node_modules/preact": { - "version": "10.20.1", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.20.1.tgz", - "integrity": "sha512-JIFjgFg9B2qnOoGiYMVBtrcFxHqn+dNXbq76bVmcaHYJFYR4lW67AOcXgAYQQTDYXDOg/kTZrKPNCdRgJ2UJmw==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, "node_modules/prebuild-install": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.2.tgz", @@ -18309,37 +18006,6 @@ "react-is": "^16.13.1" } }, - "node_modules/react-instantsearch": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/react-instantsearch/-/react-instantsearch-7.7.1.tgz", - "integrity": "sha512-o6nLY4IZWql6m0LYFSKpPKlAZ8zV3fwnwgswGs1okdw2skb3TXB535/mQCQZF39YjrUqBc3thl/YMnEDnKtVaQ==", - "dependencies": { - "@babel/runtime": "^7.1.2", - "instantsearch-ui-components": "0.4.0", - "instantsearch.js": "4.66.1", - "react-instantsearch-core": "7.7.1" - }, - "peerDependencies": { - "algoliasearch": ">= 3.1 < 5", - "react": ">= 16.8.0 < 19", - "react-dom": ">= 16.8.0 < 19" - } - }, - "node_modules/react-instantsearch-core": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/react-instantsearch-core/-/react-instantsearch-core-7.7.1.tgz", - "integrity": "sha512-OTvf/QtJT5zd+EQW+osjPPFNr7Vo9FAzy/zUxeeP+87IS6tiUpQQEDhgFFYBbvU5+97pYl9YmvGQARakNDHJOw==", - "dependencies": { - "@babel/runtime": "^7.1.2", - "algoliasearch-helper": "3.17.0", - "instantsearch.js": "4.66.1", - "use-sync-external-store": "^1.0.0" - }, - "peerDependencies": { - "algoliasearch": ">= 3.1 < 5", - "react": ">= 16.8.0 < 19" - } - }, "node_modules/react-intl": { "version": "6.6.4", "resolved": "https://registry.npmjs.org/react-intl/-/react-intl-6.6.4.tgz", @@ -19728,11 +19394,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/search-insights": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.13.0.tgz", - "integrity": "sha512-Orrsjf9trHHxFRuo9/rzm0KIWmgzE8RMlZMzuhZOJ01Rnz3D0YBAe+V6473t6/H6c7irs6Lt48brULAiRWb3Vw==" - }, "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", diff --git a/package.json b/package.json index 32987c4d0a..75fa7904e0 100644 --- a/package.json +++ b/package.json @@ -73,16 +73,15 @@ "email-validator": "2.0.4", "file-saver": "^2.0.5", "formik": "2.2.6", - "instantsearch.css": "^8.1.0", "jszip": "^3.10.1", "lodash": "4.17.21", + "meilisearch": "^0.38.0", "moment": "2.29.4", "prop-types": "15.7.2", "react": "17.0.2", "react-datepicker": "^4.13.0", "react-dom": "17.0.2", "react-helmet": "^6.1.0", - "react-instantsearch": "^7.7.1", "react-redux": "7.2.9", "react-responsive": "9.0.2", "react-router": "6.16.0", diff --git a/src/search-modal/ClearFiltersButton.jsx b/src/search-modal/ClearFiltersButton.jsx index 2b33c981d2..40e88d4e17 100644 --- a/src/search-modal/ClearFiltersButton.jsx +++ b/src/search-modal/ClearFiltersButton.jsx @@ -1,20 +1,20 @@ /* eslint-disable react/prop-types */ // @ts-check import React from 'react'; -import { useClearRefinements } from 'react-instantsearch'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { Button } from '@openedx/paragon'; import messages from './messages'; +import { useSearchContext } from './manager/SearchManager'; /** * A button that appears when at least one filter is active, and will clear the filters when clicked. * @type {React.FC>} */ const ClearFiltersButton = () => { - const { refine, canRefine } = useClearRefinements(); - if (canRefine) { + const { canClearFilters, clearFilters } = useSearchContext(); + if (canClearFilters) { return ( - ); diff --git a/src/search-modal/EmptyStates.jsx b/src/search-modal/EmptyStates.jsx index f878f433b2..fa5c77b227 100644 --- a/src/search-modal/EmptyStates.jsx +++ b/src/search-modal/EmptyStates.jsx @@ -2,9 +2,9 @@ // @ts-check import React from 'react'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; -import { Stack } from '@openedx/paragon'; -import { useStats, useClearRefinements } from 'react-instantsearch'; +import { Alert, Stack } from '@openedx/paragon'; +import { useSearchContext } from './manager/SearchManager'; import EmptySearchImage from './images/empty-search.svg'; import NoResultImage from './images/no-results.svg'; import messages from './messages'; @@ -24,10 +24,21 @@ const InfoMessage = ({ title, subtitle, image }) => ( * @type {React.FC<{children: React.ReactElement}>} */ const EmptyStates = ({ children }) => { - const { nbHits, query } = useStats(); - const { canRefine: hasFiltersApplied } = useClearRefinements(); - const hasQuery = !!query; + const { + canClearFilters: hasFiltersApplied, + totalHits, + searchKeywords, + hasError, + } = useSearchContext(); + const hasQuery = !!searchKeywords; + if (hasError) { + return ( + + + + ); + } if (!hasQuery && !hasFiltersApplied) { // We haven't started the search yet. Display the "start your search" empty state return ( @@ -38,7 +49,7 @@ const EmptyStates = ({ children }) => { /> ); } - if (nbHits === 0) { + if (totalHits === 0) { return ( { const { - items, - refine, - canToggleShowMore, - isShowingMore, - toggleShowMore, - } = useRefinementList({ attribute: 'block_type', sortBy: ['count:desc', 'name'] }); - - // Get the list of applied 'items' (selected block types to filter) in the original order that the user clicked them. - // The first choice will be shown on the button, and we don't want it to change as the user selects more options. - // (But for the dropdown menu, we always want them sorted by 'count:desc' and 'name'; not in order of selection.) - const refinementsData = useCurrentRefinements({ includedAttributes: ['block_type'] }); - const appliedItems = refinementsData.items[0]?.refinements ?? []; - // If we didn't need to preserve the order the user clicked on, the above two lines could be simplified to: - // const appliedItems = items.filter(item => item.isRefined); + blockTypes, + blockTypesFilter, + setBlockTypesFilter, + } = useSearchContext(); + // TODO: sort blockTypes first by count, then by name const handleCheckboxChange = React.useCallback((e) => { - refine(e.target.value); - }, [refine]); + setBlockTypesFilter(currentFilters => { + if (currentFilters.includes(e.target.value)) { + return currentFilters.filter(x => x !== e.target.value); + } + return [...currentFilters, e.target.value]; + }); + }, [setBlockTypesFilter]); return ( ({ label: }))} + appliedFilters={blockTypesFilter.map(blockType => ({ label: }))} label={} > item.value)} + defaultValue={blockTypesFilter} > { - items.map((item) => ( + Object.entries(blockTypes).map(([blockType, count]) => ( - {' '} - {item.count} + {' '} + {count} )) } { // Show a message if there are no options at all to avoid the impression that the dropdown isn't working - items.length === 0 ? ( + blockTypes.length === 0 ? ( ) : null } - { - canToggleShowMore && !isShowingMore - ? - : null - } ); }; diff --git a/src/search-modal/FilterByTags.jsx b/src/search-modal/FilterByTags.jsx index 68bd92ee70..d36c3e8cfc 100644 --- a/src/search-modal/FilterByTags.jsx +++ b/src/search-modal/FilterByTags.jsx @@ -1,117 +1,212 @@ /* eslint-disable react/prop-types */ // @ts-check import React from 'react'; -import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { - Button, Badge, Form, + Icon, + IconButton, Menu, MenuItem, + SearchField, } from '@openedx/paragon'; -import { useHierarchicalMenu } from 'react-instantsearch'; +import { ArrowDropDown, ArrowDropUp, Warning } from '@openedx/paragon/icons'; import SearchFilterWidget from './SearchFilterWidget'; import messages from './messages'; +import { useSearchContext } from './manager/SearchManager'; +import { useTagFilterOptions } from './data/apiHooks'; +import { LoadingSpinner } from '../generic/Loading'; +import { TAG_SEP } from './data/api'; -// eslint-disable-next-line max-len -/** @typedef {import('instantsearch.js/es/connectors/hierarchical-menu/connectHierarchicalMenu').HierarchicalMenuItem} HierarchicalMenuItem */ +/** + * A menu item with a checkbox and an optional ▼ button (to show/hide children) + * @type {React.FC<{ + * label: string; + * tagPath: string; + * isChecked: boolean; + * onClickCheckbox: () => void; + * tagCount: number; + * hasChildren?: boolean; + * isExpanded?: boolean; + * onToggleChildren?: (tagPath: string) => void; + * }>} + */ +const TagMenuItem = ({ + label, + tagPath, + tagCount, + isChecked, + onClickCheckbox, + hasChildren, + isExpanded, + onToggleChildren, +}) => { + const intl = useIntl(); + const randomNumber = React.useMemo(() => Math.floor(Math.random() * 1000), []); + const checkboxId = tagPath.replace(/[\W]/g, '_') + randomNumber; + + return ( +
+ + + { + hasChildren + ? ( + onToggleChildren?.(tagPath)} + variant="primary" + size="sm" + /> + ) : null + } +
+ ); +}; /** - * A button with a dropdown menu to allow filtering the search using tags. - * This version is based on Instantsearch's component, so it only allows selecting one tag at a - * time. We will replace it with a custom version that allows multi-select. + * A list of menu items with all of the options for tags at one level of the hierarchy. * @type {React.FC<{ - * items: HierarchicalMenuItem[], - * refine: (value: string) => void, - * depth?: number, + * tagSearchKeywords: string; + * parentTagPath?: string; + * toggleTagChildren?: (tagPath: string) => void; + * expandedTags: string[], * }>} */ -const FilterOptions = ({ items, refine, depth = 0 }) => { - const handleCheckboxChange = React.useCallback((e) => { - refine(e.target.value); - }, [refine]); +const TagOptions = ({ + parentTagPath = '', + tagSearchKeywords, + expandedTags, + toggleTagChildren, +}) => { + const searchContext = useSearchContext(); + const { data, isLoading, isError } = useTagFilterOptions({ + ...searchContext, + parentTagPath, + tagSearchKeywords, + }); + + if (isError) { + return ; + } + if (isLoading || data.tags === undefined) { + return ; + } + + // Show a message if there are no options at all to avoid the impression that the dropdown isn't working + if (data.tags.length === 0 && !parentTagPath) { + return ; + } return ( - <> +
+ { + data.tags.map(({ tagName, tagPath, ...t }) => { + const isExpanded = expandedTags.includes(tagPath); + return ( + + { + searchContext.setTagsFilter((tf) => ( + tf.includes(tagPath) ? tf.filter(tp => tp !== tagPath) : [...tf, tagPath] + )); + }} + hasChildren={t.hasChildren} + isExpanded={isExpanded} + onToggleChildren={toggleTagChildren} + /> + {isExpanded ? ( +
+ +
+ ) : null} +
+ ); + }) + } { - items.map((item) => ( - - - {item.label}{' '} - {item.count} + // Sometimes, due to limitations of how the search index/API works, we aren't able to retrieve all the options: + data.mayBeMissingResults + ? ( + + - {item.data && } - - )) + ) : null } - +
); }; /** @type {React.FC} */ const FilterByTags = () => { - const { - items, - refine, - canToggleShowMore, - isShowingMore, - toggleShowMore, - } = useHierarchicalMenu({ - attributes: [ - 'tags.taxonomy', - 'tags.level0', - 'tags.level1', - 'tags.level2', - 'tags.level3', - ], - }); + const intl = useIntl(); + const { tagsFilter } = useSearchContext(); + const [tagSearchKeywords, setTagSearchKeywords] = React.useState(''); - // Recurse over the 'items' tree and find all the selected leaf tags - (with no children that are checked/"refined") - const appliedItems = React.useMemo(() => { - /** @type {{label: string}[]} */ - const result = []; - /** @type {(itemSet: HierarchicalMenuItem[]) => void} */ - const findSelectedLeaves = (itemSet) => { - itemSet.forEach(item => { - if (item.isRefined && item.data?.find(child => child.isRefined) === undefined) { - result.push({ label: item.label }); - } - if (item.data) { - findSelectedLeaves(item.data); - } - }); - }; - findSelectedLeaves(items); - return result; - }, [items]); + // e.g. {"Location", "Location > North America"} if those two paths of the tag tree are expanded + const [expandedTags, setExpandedTags] = React.useState(/** @type {string[]} */([])); + const toggleTagChildren = React.useCallback(tagWithLineage => { + setExpandedTags(currentList => { + if (currentList.includes(tagWithLineage)) { + return currentList.filter(x => x !== tagWithLineage); + } + return [...currentList, tagWithLineage]; + }); + }, [setExpandedTags]); return ( ({ label: tf.split(TAG_SEP).pop() }))} label={} > - - - - { - // Show a message if there are no options at all to avoid the impression that the dropdown isn't working - items.length === 0 ? ( - - ) : null - } + + setTagSearchKeywords('')} + value={tagSearchKeywords} + screenReaderText={{ + label: intl.formatMessage(messages.searchTagsByKeywordPlaceholder), + submitButton: intl.formatMessage(messages.submitSearchTagsByKeyword), + }} + placeholder={intl.formatMessage(messages.searchTagsByKeywordPlaceholder)} + className="mx-3 mb-1" + /> + + - { - canToggleShowMore && !isShowingMore - ? - : null - } ); }; diff --git a/src/search-modal/Highlight.jsx b/src/search-modal/Highlight.jsx new file mode 100644 index 0000000000..46ddef9f8f --- /dev/null +++ b/src/search-modal/Highlight.jsx @@ -0,0 +1,28 @@ +/* eslint-disable react/no-array-index-key */ +/* eslint-disable react/prop-types */ +// @ts-check +import React from 'react'; + +import { highlightPostTag, highlightPreTag } from './data/api'; + +/** + * Render some text that contains matching words which should be highlighted + * @type {React.FC<{text: string}>} + */ +const Highlight = ({ text }) => { + const parts = text.split(highlightPreTag); + return ( + + {parts.map((part, idx) => { + if (idx === 0) { return {part}; } + const endIdx = part.indexOf(highlightPostTag); + if (endIdx === -1) { return {part}; } + const highLightPart = part.substring(0, endIdx); + const otherPart = part.substring(endIdx + highlightPostTag.length); + return {highLightPart}{otherPart}; + })} + + ); +}; + +export default Highlight; diff --git a/src/search-modal/SearchEndpointLoader.jsx b/src/search-modal/SearchEndpointLoader.jsx deleted file mode 100644 index 10c5eadcda..0000000000 --- a/src/search-modal/SearchEndpointLoader.jsx +++ /dev/null @@ -1,41 +0,0 @@ -/* eslint-disable react/prop-types */ -// @ts-check -import React from 'react'; -import { ModalDialog } from '@openedx/paragon'; -import { ErrorAlert } from '@edx/frontend-lib-content-components'; -import { useIntl } from '@edx/frontend-platform/i18n'; - -import { LoadingSpinner } from '../generic/Loading'; -import { useContentSearch } from './data/apiHooks'; -import SearchUI from './SearchUI'; -import messages from './messages'; - -/** @type {React.FC<{courseId: string, closeSearch?: () => void}>} */ -const SearchEndpointLoader = ({ courseId, closeSearch }) => { - const intl = useIntl(); - - // Load the Meilisearch connection details from the LMS: the URL to use, the index name, and an API key specific - // to us (to the current user) that allows us to search all content we have permission to view. - const { - data: searchEndpointData, - isLoading, - error, - } = useContentSearch(); - - const title = intl.formatMessage(messages.title); - - if (searchEndpointData) { - return ; - } - return ( - <> - {title} - - {/* @ts-ignore */} - {isLoading ? : {error?.message ?? String(error)}} - - - ); -}; - -export default SearchEndpointLoader; diff --git a/src/search-modal/SearchKeywordsField.jsx b/src/search-modal/SearchKeywordsField.jsx index 15614d8c8b..809dd7b430 100644 --- a/src/search-modal/SearchKeywordsField.jsx +++ b/src/search-modal/SearchKeywordsField.jsx @@ -1,25 +1,25 @@ /* eslint-disable react/prop-types */ // @ts-check import React from 'react'; -import { useSearchBox } from 'react-instantsearch'; import { useIntl } from '@edx/frontend-platform/i18n'; import { SearchField } from '@openedx/paragon'; import messages from './messages'; +import { useSearchContext } from './manager/SearchManager'; /** * The "main" input field where users type in search keywords. The search happens as they type (no need to press enter). - * @type {React.FC} + * @type {React.FC<{className?: string}>} */ const SearchKeywordsField = (props) => { const intl = useIntl(); - const { query, refine } = useSearchBox(props); + const { searchKeywords, setSearchKeywords } = useSearchContext(); return ( refine('')} - value={query} + onSubmit={setSearchKeywords} + onChange={setSearchKeywords} + onClear={() => setSearchKeywords('')} + value={searchKeywords} className={props.className} placeholder={intl.formatMessage(messages.inputPlaceholder)} /> diff --git a/src/search-modal/SearchModal.jsx b/src/search-modal/SearchModal.jsx index d7cd9ea91b..22171e87cd 100644 --- a/src/search-modal/SearchModal.jsx +++ b/src/search-modal/SearchModal.jsx @@ -4,8 +4,8 @@ import React from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; import { ModalDialog } from '@openedx/paragon'; -import SearchEndpointLoader from './SearchEndpointLoader'; import messages from './messages'; +import SearchUI from './SearchUI'; /** @type {React.FC<{courseId: string, isOpen: boolean, onClose: () => void}>} */ const SearchModal = ({ courseId, ...props }) => { @@ -24,7 +24,7 @@ const SearchModal = ({ courseId, ...props }) => { isFullscreenOnMobile className="courseware-search-modal" > - + ); }; diff --git a/src/search-modal/SearchModal.scss b/src/search-modal/SearchModal.scss index 50b1906208..228df4407c 100644 --- a/src/search-modal/SearchModal.scss +++ b/src/search-modal/SearchModal.scss @@ -16,6 +16,14 @@ // (If we set 'isOverflowVisible: true', the scrolling of the results list is messed up) overflow: visible; + // Highlight matching terms using bold, not yellow highlighting + mark { + font-weight: bold; + background-color: transparent; + padding: 0; + display: inline; + } + .pgn__modal-header .pgn__menu-select { // The "All courses" / "This course" toggle button & > button { @@ -27,24 +35,10 @@ } // Options for the "filter by tag" menu - .pgn__menu { - $indent-initial: 1.3rem; - $indent-each: 1.6rem; - - .tag-option-1 { - padding-left: $indent-initial + (1 * $indent-each); - } - - .tag-option-2 { - padding-left: $indent-initial + (2 * $indent-each); - } - - .tag-option-3 { - padding-left: $indent-initial + (3 * $indent-each); - } - - .tag-option-4 { - padding-left: $indent-initial + (4 * $indent-each); + .pgn__menu.tags-refinement-menu { + .pgn__menu-item { + // Make the "filter by tag" menu much wider than normal, because we need the space to display the tags hierarchy + width: 100%; } } diff --git a/src/search-modal/SearchModal.test.jsx b/src/search-modal/SearchModal.test.jsx index 6c77043958..a14bb0fc2d 100644 --- a/src/search-modal/SearchModal.test.jsx +++ b/src/search-modal/SearchModal.test.jsx @@ -67,17 +67,9 @@ describe('', () => { expect(await findByText('Start searching to find content')).toBeInTheDocument(); }); - it('should render the spinner while the config is loading', () => { - axiosMock.onGet(getContentSearchConfigUrl()).replyOnce(200, new Promise(() => {})); // never resolves - const { getByRole } = render(); - - const spinner = getByRole('status'); - expect(spinner.textContent).toEqual('Loading...'); - }); - it('should render the error message if the api call throws', async () => { axiosMock.onGet(getContentSearchConfigUrl()).networkError(); const { findByText } = render(); - expect(await findByText('Network Error')).toBeInTheDocument(); + expect(await findByText('An error occurred. Unable to load search results.')).toBeInTheDocument(); }); }); diff --git a/src/search-modal/SearchResult.jsx b/src/search-modal/SearchResult.jsx index 62abf56840..c15371d81b 100644 --- a/src/search-modal/SearchResult.jsx +++ b/src/search-modal/SearchResult.jsx @@ -1,6 +1,6 @@ /* eslint-disable react/prop-types */ // @ts-check -import React, { useCallback, useMemo } from 'react'; +import React from 'react'; import { getConfig, getPath } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; import { @@ -12,78 +12,42 @@ import { Article, Folder, OpenInNew, - Question, - TextFields, - Videocam, } from '@openedx/paragon/icons'; -import { - Highlight, - Snippet, -} from 'react-instantsearch'; import { useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; +import { COMPONENT_TYPE_ICON_MAP, TYPE_ICONS_MAP } from '../course-unit/constants'; import { getStudioHomeData } from '../studio-home/data/selectors'; +import { useSearchContext } from './manager/SearchManager'; +import Highlight from './Highlight'; import messages from './messages'; -/** - * @typedef {import('instantsearch.js').Hit<{ - * id: string, - * usage_key: string, - * context_key: string, - * display_name: string, - * block_type: string, - * 'content.html_content'?: string, - * 'content.capa_content'?: string, - * breadcrumbs: {display_name: string}[] - * breadcrumbsNames: string[], - * }>} CustomHit - */ - -/** - * Custom Highlight component that uses the tag for highlighting - * @type {React.FC<{ - * attribute: keyof CustomHit | string[], - * hit: CustomHit, - * separator?: string, - * }>} - */ -const CustomHighlight = ({ attribute, hit, separator }) => ( - -); - -const ItemIcon = { - vertical: Folder, +const STRUCTURAL_TYPE_ICONS = { + vertical: TYPE_ICONS_MAP.vertical, sequential: Folder, chapter: Folder, - problem: Question, - video: Videocam, - html: TextFields, }; +/** @param {string} blockType */ +function getItemIcon(blockType) { + return STRUCTURAL_TYPE_ICONS[blockType] ?? COMPONENT_TYPE_ICON_MAP[blockType] ?? Article; +} + /** * A single search result (row), usually represents an XBlock/Component - * @type {React.FC<{ hit: CustomHit, closeSearch?: () => void}>} + * @type {React.FC<{hit: import('./data/api').ContentHit}>} */ -const SearchResult = ({ hit, closeSearch }) => { +const SearchResult = ({ hit }) => { const intl = useIntl(); const navigate = useNavigate(); + const { closeSearchModal } = useSearchContext(); const { libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe } = useSelector(getStudioHomeData); /** * Returns the URL for the context of the hit - * @param {CustomHit} hit - * @param {boolean?} newWindow - * @param {string} libraryAuthoringMfeUrl - * @returns {string?} */ - const getContextUrl = useCallback((newWindow) => { - const { context_key: contextKey, usage_key: usageKey } = hit; + const getContextUrl = React.useCallback((newWindow = false) => { + const { contextKey, usageKey } = hit; if (contextKey.startsWith('course-v1:')) { const courseSufix = `course/${contextKey}?show=${encodeURIComponent(usageKey)}`; if (newWindow) { @@ -101,30 +65,31 @@ const SearchResult = ({ hit, closeSearch }) => { return undefined; }, [libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe]); - const redirectUrl = useMemo(() => getContextUrl(), [libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe]); - const newWindowUrl = useMemo(() => getContextUrl(true), [libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe]); + const redirectUrl = React.useMemo(() => getContextUrl(), [libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe]); + const newWindowUrl = React.useMemo( + () => getContextUrl(true), + [libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe], + ); /** - * Opens the context of the hit in a new window - * @param {React.MouseEvent} e - * @returns {void} - * */ + * Opens the context of the hit in a new window + * @param {React.MouseEvent} e + * @returns {void} + */ const openContextInNewWindow = (e) => { e.stopPropagation(); - /* istanbul ignore next */ if (!newWindowUrl) { return; } - window.open(newWindowUrl, '_blank'); }; /** - * Navigates to the context of the hit - * @param {(React.MouseEvent | React.KeyboardEvent)} e - * @returns {void} - * */ + * Navigates to the context of the hit + * @param {(React.MouseEvent | React.KeyboardEvent)} e + * @returns {void} + */ const navigateToContext = (e) => { e.stopPropagation(); @@ -146,7 +111,7 @@ const SearchResult = ({ hit, closeSearch }) => { } navigate(redirectUrl); - closeSearch?.(); + closeSearchModal(); }; return ( @@ -159,17 +124,17 @@ const SearchResult = ({ hit, closeSearch }) => { tabIndex={redirectUrl ? 0 : undefined} role="button" > - +
- +
- - + +
- + {hit.breadcrumbs.map(bc => bc.displayName).join(' / ')}
>} + */ +const SearchResults = () => { + const intl = useIntl(); + const { + hits, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useSearchContext(); + + const labels = { + default: intl.formatMessage(messages.showMoreResults), + pending: intl.formatMessage(messages.loadingMoreResults), + }; + + return ( + <> + {hits.map((hit) => )} + {hasNextPage + ? ( + + ) : null} + + ); +}; + +export default SearchResults; diff --git a/src/search-modal/SearchUI.jsx b/src/search-modal/SearchUI.jsx index 1fc44643e6..be85e97632 100644 --- a/src/search-modal/SearchUI.jsx +++ b/src/search-modal/SearchUI.jsx @@ -1,58 +1,37 @@ /* eslint-disable react/prop-types */ // @ts-check -import React, { useCallback } from 'react'; +import React from 'react'; import { MenuItem, ModalDialog, SelectMenu, } from '@openedx/paragon'; import { Check } from '@openedx/paragon/icons'; -import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; -import { Configure, InfiniteHits, InstantSearch } from 'react-instantsearch'; -import { instantMeiliSearch } from '@meilisearch/instant-meilisearch'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; import ClearFiltersButton from './ClearFiltersButton'; import EmptyStates from './EmptyStates'; -import SearchResult from './SearchResult'; +import SearchResults from './SearchResults'; import SearchKeywordsField from './SearchKeywordsField'; import FilterByBlockType from './FilterByBlockType'; import FilterByTags from './FilterByTags'; import Stats from './Stats'; +import { SearchContextProvider } from './manager/SearchManager'; import messages from './messages'; -/** @type {React.FC<{courseId: string, url: string, apiKey: string, indexName: string, closeSearch?: () => void}>} */ +/** @type {React.FC<{courseId: string, closeSearchModal?: () => void}>} */ const SearchUI = (props) => { - const { searchClient } = React.useMemo( - () => instantMeiliSearch(props.url, props.apiKey, { primaryKey: 'id' }), - [props.url, props.apiKey], - ); - - const intl = useIntl(); - const hasCourseId = Boolean(props.courseId); const [_searchThisCourseEnabled, setSearchThisCourse] = React.useState(hasCourseId); const switchToThisCourse = React.useCallback(() => setSearchThisCourse(true), []); const switchToAllCourses = React.useCallback(() => setSearchThisCourse(false), []); const searchThisCourse = hasCourseId && _searchThisCourseEnabled; - const HitComponent = useCallback( - ({ hit }) => , - [], - ); - return ( - - {/* Add in a filter for the current course, if relevant */} - - {/* We need to override z-index here or the appears behind the * But it can't be more then 9 because the close button has z-index 10. */} @@ -88,38 +67,10 @@ const SearchUI = (props) => { {/* If there are no results (yet), EmptyStates displays a friendly messages. Otherwise we see the results. */} - items.map((item) => ({ - ...item, - breadcrumbsNames: searchThisCourse - ? item.breadcrumbs.slice(1).map((bc) => bc.display_name) - : item.breadcrumbs.map((bc) => bc.display_name), - _highlightResult: { - // eslint-disable-next-line no-underscore-dangle - ...item._highlightResult, - breadcrumbsNames: searchThisCourse - // @ts-ignore - // eslint-disable-next-line no-underscore-dangle - ? item._highlightResult?.breadcrumbs.slice(1).map((bc) => bc.display_name) - // @ts-ignore - // eslint-disable-next-line no-underscore-dangle - : item._highlightResult?.breadcrumbs.map((bc) => bc.display_name), - }, - }))} - /> + - + ); }; diff --git a/src/search-modal/SearchUI.test.jsx b/src/search-modal/SearchUI.test.jsx index 3e217c4a7d..752bd7c584 100644 --- a/src/search-modal/SearchUI.test.jsx +++ b/src/search-modal/SearchUI.test.jsx @@ -3,8 +3,10 @@ import React from 'react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { AppProvider } from '@edx/frontend-platform/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import MockAdapter from 'axios-mock-adapter'; import { fireEvent, render, @@ -19,7 +21,16 @@ import initializeStore from '../store'; import mockResult from './__mocks__/search-result.json'; // @ts-ignore import mockEmptyResult from './__mocks__/empty-search-result.json'; +// @ts-ignore +import mockTagsFacetResult from './__mocks__/facet-search.json'; +// @ts-ignore +import mockTagsFacetResultLevel0 from './__mocks__/facet-search-level0.json'; +// @ts-ignore +import mockTagsFacetResultLevel1 from './__mocks__/facet-search-level1.json'; +// @ts-ignore +import mockTagsKeywordSearchResult from './__mocks__/tags-keyword-search.json'; import SearchUI from './SearchUI'; +import { getContentSearchConfigUrl } from './data/api'; // mockResult contains only a single result - this one: const mockResultDisplayName = 'Test HTML Block'; @@ -29,12 +40,11 @@ const queryClient = new QueryClient(); // Default props for const defaults = { - url: 'http://mock.meilisearch.local/', - apiKey: 'test-key', - indexName: 'studio', courseId: 'course-v1:org+test+123', }; const searchEndpoint = 'http://mock.meilisearch.local/multi-search'; +const facetSearchEndpoint = 'http://mock.meilisearch.local/indexes/studio/facet-search'; +const tagsKeywordSearchEndpoint = 'http://mock.meilisearch.local/indexes/studio/search'; const mockNavigate = jest.fn(); @@ -53,15 +63,15 @@ const Wrap = ({ children }) => ( ); +let axiosMock; const returnEmptyResult = (_url, req) => { const requestData = JSON.parse(req.body?.toString() ?? ''); const query = requestData?.queries[0]?.q ?? ''; // We have to replace the query (search keywords) in the mock results with the actual query, - // because otherwise Instantsearch will update the UI and change the query, - // leading to unexpected results in the test cases. + // because otherwise we may have an inconsistent state that causes more queries and unexpected results. mockEmptyResult.results[0].query = query; - // And create the required '_formatted' field; not sure why it's there - seems very redundant. But it's required. + // And fake the required '_formatted' fields; it contains the highlighting ... around matched words // eslint-disable-next-line no-underscore-dangle, no-param-reassign mockEmptyResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; }); return mockEmptyResult; @@ -78,6 +88,14 @@ describe('', () => { }, }); store = initializeStore(); + // The API method to get the Meilisearch connection details uses Axios: + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock.onGet(getContentSearchConfigUrl()).reply(200, { + url: 'http://mock.meilisearch.local', + index_name: 'studio', + api_key: 'test-key', + }); + // The Meilisearch client-side API uses fetch, not Axios. fetchMock.post(searchEndpoint, (_url, req) => { const requestData = JSON.parse(req.body?.toString() ?? ''); const query = requestData?.queries[0]?.q ?? ''; @@ -85,11 +103,12 @@ describe('', () => { // because otherwise Instantsearch will update the UI and change the query, // leading to unexpected results in the test cases. mockResult.results[0].query = query; - // And create the required '_formatted' field; not sure why it's there - seems very redundant. But it's required. + // And fake the required '_formatted' fields; it contains the highlighting ... around matched words // eslint-disable-next-line no-underscore-dangle, no-param-reassign mockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; }); return mockResult; }); + fetchMock.post(tagsKeywordSearchEndpoint, mockTagsKeywordSearchResult); }); afterEach(async () => { @@ -100,8 +119,8 @@ describe('', () => { const { getByText } = render(); // Before the results have even loaded, we see this message: expect(getByText('Start searching to find content')).toBeInTheDocument(); - // When this UI loads, Instantsearch makes two queries. I think one to load the facets and one "blank" search. - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); + // When this UI loads, we do a "placeholder" search to load the filter options + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); // And that message is still displayed even after the initial results/filters have loaded: expect(getByText('Start searching to find content')).toBeInTheDocument(); }); @@ -112,14 +131,14 @@ describe('', () => { // Return an empty result set: // Before the results have even loaded, we see this message: expect(getByText('Start searching to find content')).toBeInTheDocument(); - // When this UI loads, Instantsearch makes two queries. I think one to load the facets and one "blank" search. - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); + // When this UI loads, the UI makes a search, to get the available "block type" facet values. + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); // And that message is still displayed even after the initial results/filters have loaded: expect(getByText('Start searching to find content')).toBeInTheDocument(); // Enter a keyword - search for 'noresults': fireEvent.change(getByRole('searchbox'), { target: { value: 'noresults' } }); // Wait for the new search request to load all the results: - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(3, searchEndpoint, 'post'); }); + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); expect(getByText('We didn\'t find anything matching your search')).toBeInTheDocument(); }); @@ -129,18 +148,18 @@ describe('', () => { expect(getByText('All courses')).toBeInTheDocument(); expect(queryByText('This course')).toBeNull(); // Wait for the initial search request that loads all the filter options: - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); // Enter a keyword - search for 'giraffe': fireEvent.change(getByRole('searchbox'), { target: { value: 'giraffe' } }); // Wait for the new search request to load all the results: - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(3, searchEndpoint, 'post'); }); + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); // Now we should see the results: expect(queryByText('Enter a keyword')).toBeNull(); // The result: expect(getByText('2 results found')).toBeInTheDocument(); expect(getByText(mockResultDisplayName)).toBeInTheDocument(); // Breadcrumbs showing where the result came from: - expect(getByText('The Little Unit That Could')).toBeInTheDocument(); + expect(getByText('TheCourse / Section 2 / Subsection 3 / The Little Unit That Could')).toBeInTheDocument(); const resultItem = getByRole('button', { name: /The Little Unit That Could/ }); @@ -165,11 +184,11 @@ describe('', () => { expect(getByText('This course')).toBeInTheDocument(); expect(queryByText('All courses')).toBeNull(); // Wait for the initial search request that loads all the filter options: - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); // Enter a keyword - search for 'giraffe': fireEvent.change(getByRole('searchbox'), { target: { value: 'giraffe' } }); // Wait for the new search request to load all the results: - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(3, searchEndpoint, 'post'); }); + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); // And make sure the request was limited to this course: expect(fetchMock).toHaveLastFetched((_url, req) => { const requestData = JSON.parse(req.body?.toString() ?? ''); @@ -182,21 +201,31 @@ describe('', () => { expect(getByText('2 results found')).toBeInTheDocument(); expect(getByText(mockResultDisplayName)).toBeInTheDocument(); // Breadcrumbs showing where the result came from: - expect(getByText('The Little Unit That Could')).toBeInTheDocument(); + expect(getByText('TheCourse / Section 2 / Subsection 3 / The Little Unit That Could')).toBeInTheDocument(); }); describe('filters', () => { /** @type {import('@testing-library/react').RenderResult} */ let rendered; beforeEach(async () => { + fetchMock.post(facetSearchEndpoint, (_path, req) => { + const requestData = JSON.parse(req.body?.toString() ?? ''); + switch (requestData.facetName) { + case 'tags.taxonomy': return mockTagsFacetResult; + case 'tags.level0': return mockTagsFacetResultLevel0; + case 'tags.level1': return mockTagsFacetResultLevel1; + default: throw new Error(`Facet ${requestData.facetName} not mocked for testing`); + } + }); + rendered = render(); const { getByRole, getByText } = rendered; // Wait for the initial search request that loads all the filter options: - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); // Enter a keyword - search for 'giraffe': fireEvent.change(getByRole('searchbox'), { target: { value: 'giraffe' } }); // Wait for the new search request to load all the results and the filter options, based on the search so far: - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(3, searchEndpoint, 'post'); }); + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); // And make sure the request was limited to this course: expect(fetchMock).toHaveLastFetched((_url, req) => { const requestData = JSON.parse(req.body?.toString() ?? ''); @@ -217,8 +246,9 @@ describe('', () => { const popupMenu = getByRole('group'); const problemFilterCheckbox = getByLabelTextIn(popupMenu, /Problem/i); fireEvent.click(problemFilterCheckbox, {}); + await waitFor(() => { expect(rendered.getByText('Type: Problem')).toBeInTheDocument(); }); // Now wait for the filter to be applied and the new results to be fetched. - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(4, searchEndpoint, 'post'); }); + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(3, searchEndpoint, 'post'); }); // Because we're mocking the results, there's no actual changes to the mock results, // but we can verify that the filter was sent in the request expect(fetchMock).toHaveLastFetched((_url, req) => { @@ -226,7 +256,7 @@ describe('', () => { const requestedFilter = requestData?.queries[0].filter; return JSON.stringify(requestedFilter) === JSON.stringify([ 'context_key = "course-v1:org+test+123"', - ['"block_type"="problem"'], // <-- the newly added filter, sent with the request + ['block_type = problem'], // <-- the newly added filter, sent with the request ]); }); }); @@ -236,20 +266,72 @@ describe('', () => { // Now open the filters menu: fireEvent.click(getByRole('button', { name: 'Tags' }), {}); // The dropdown menu in this case doesn't have a role; let's just assume it's displayed. - const competentciesCheckbox = getByLabelText(/ESDC Skills and Competencies/i); + const checkboxLabel = /^ESDC Skills and Competencies/i; + await waitFor(() => { expect(getByLabelText(checkboxLabel)).toBeInTheDocument(); }); + // In addition to the checkbox, there is another button to show the child tags: + expect(getByLabelText(/Expand to show child tags of "ESDC Skills and Competencies"/i)).toBeInTheDocument(); + const competentciesCheckbox = getByLabelText(checkboxLabel); fireEvent.click(competentciesCheckbox, {}); // Now wait for the filter to be applied and the new results to be fetched. - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(4, searchEndpoint, 'post'); }); + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(3, searchEndpoint, 'post'); }); // Because we're mocking the results, there's no actual changes to the mock results, // but we can verify that the filter was sent in the request - expect(fetchMock).toHaveLastFetched((_url, req) => { + expect(fetchMock).toBeDone((_url, req) => { const requestData = JSON.parse(req.body?.toString() ?? ''); - const requestedFilter = requestData?.queries[0].filter; + const requestedFilter = requestData?.queries?.[0]?.filter; return JSON.stringify(requestedFilter) === JSON.stringify([ 'context_key = "course-v1:org+test+123"', - ['"tags.taxonomy"="ESDC Skills and Competencies"'], // <-- the newly added filter, sent with the request + 'tags.taxonomy = "ESDC Skills and Competencies"', // <-- the newly added filter, sent with the request ]); }); }); + + it('can filter results by a child tag', async () => { + const { getByRole, getByLabelText, queryByLabelText } = rendered; + // Now open the filters menu: + fireEvent.click(getByRole('button', { name: 'Tags' }), {}); + // The dropdown menu in this case doesn't have a role; let's just assume it's displayed. + const expandButtonLabel = /Expand to show child tags of "ESDC Skills and Competencies"/i; + await waitFor(() => { expect(getByLabelText(expandButtonLabel)).toBeInTheDocument(); }); + + // First, the child tag is not shown: + const childTagLabel = /^Abilities/i; + expect(queryByLabelText(childTagLabel)).toBeNull(); + // Click on the button to show children + const expandButton = getByLabelText(expandButtonLabel); + fireEvent.click(expandButton, {}); + // Now the child tag is visible: + await waitFor(() => { expect(queryByLabelText(childTagLabel)).toBeInTheDocument(); }); + // Click on it: + const abilitiesTagFilterCheckbox = getByLabelText(childTagLabel); + fireEvent.click(abilitiesTagFilterCheckbox); + // Now wait for the filter to be applied and the new results to be fetched. + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(3, searchEndpoint, 'post'); }); + // Because we're mocking the results, there's no actual changes to the mock results, + // but we can verify that the filter was sent in the request + expect(fetchMock).toBeDone((_url, req) => { + const requestData = JSON.parse(req.body?.toString() ?? ''); + const requestedFilter = requestData?.queries?.[0]?.filter; + return JSON.stringify(requestedFilter) === JSON.stringify([ + 'context_key = "course-v1:org+test+123"', + 'tags.level0 = "ESDC Skills and Competencies > Abilities"', + ]); + }); + }); + + it('can do a keyword search of the tag options', async () => { + const { getByRole, getByLabelText, queryByLabelText } = rendered; + // Now open the filters menu: + fireEvent.click(getByRole('button', { name: 'Tags' }), {}); + // The dropdown menu in this case doesn't have a role; let's just assume it's displayed. + const expandButtonLabel = /Expand to show child tags of "ESDC Skills and Competencies"/i; + await waitFor(() => { expect(getByLabelText(expandButtonLabel)).toBeInTheDocument(); }); + + const input = getByLabelText('Search tags'); + fireEvent.change(input, { target: { value: 'Lightcast' } }); + + await waitFor(() => { expect(queryByLabelText(/^ESDC Skills and Competencies/i)).toBeNull(); }); + expect(queryByLabelText(/^Lightcast/i)).toBeInTheDocument(); + }); }); }); diff --git a/src/search-modal/Stats.jsx b/src/search-modal/Stats.jsx index fabfe76a4f..63bcd8f4f5 100644 --- a/src/search-modal/Stats.jsx +++ b/src/search-modal/Stats.jsx @@ -1,26 +1,24 @@ /* eslint-disable react/prop-types */ // @ts-check import React from 'react'; -import { useStats, useClearRefinements } from 'react-instantsearch'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; import messages from './messages'; +import { useSearchContext } from './manager/SearchManager'; /** * Simple component that displays the # of matching results * @type {React.FC>} */ -const Stats = (props) => { - const { nbHits, query } = useStats(props); - const { canRefine: hasFiltersApplied } = useClearRefinements(); - const hasQuery = !!query; +const Stats = () => { + const { totalHits, searchKeywords, canClearFilters } = useSearchContext(); - if (!hasQuery && !hasFiltersApplied) { + if (!searchKeywords && !canClearFilters) { // We haven't started the search yet. return null; } return ( - + ); }; diff --git a/src/search-modal/__mocks__/empty-search-result.json b/src/search-modal/__mocks__/empty-search-result.json index 70d3e8f2fa..a0ba5d6db9 100644 --- a/src/search-modal/__mocks__/empty-search-result.json +++ b/src/search-modal/__mocks__/empty-search-result.json @@ -2,16 +2,24 @@ "comment": "This is a mock of the empty response from Meilisearch, based on an actual search in Studio.", "results": [ { - "indexUid": "tutor_studio_content", + "indexUid": "studio", "hits": [], "query": "noresult", "processingTimeMs": 0, - "limit": 21, + "limit": 20, + "offset": 0, + "estimatedTotalHits": 0 + }, + { + "indexUid": "studio", + "hits": [], + "query": "noresult", + "processingTimeMs": 0, + "limit": 0, "offset": 0, "estimatedTotalHits": 0, "facetDistribution": { - "block_type": {}, - "tags.taxonomy": {} + "block_type": {} }, "facetStats": {} } diff --git a/src/search-modal/__mocks__/facet-search-level0.json b/src/search-modal/__mocks__/facet-search-level0.json new file mode 100644 index 0000000000..b4201c6d88 --- /dev/null +++ b/src/search-modal/__mocks__/facet-search-level0.json @@ -0,0 +1,13 @@ +{ + "facetHits": [ + { "value": "ESDC Skills and Competencies > Abilities", "count": 5 }, + { "value": "ESDC Skills and Competencies > Interests", "count": 1 }, + { "value": "ESDC Skills and Competencies > Knowledge", "count": 7 }, + { "value": "ESDC Skills and Competencies > Personal Attributes", "count": 3 }, + { "value": "ESDC Skills and Competencies > Skills", "count": 8 }, + { "value": "ESDC Skills and Competencies > Work Activities", "count": 5 }, + { "value": "ESDC Skills and Competencies > Work Context", "count": 10 } + ], + "facetQuery": "", + "processingTimeMs": 0 +} diff --git a/src/search-modal/__mocks__/facet-search-level1.json b/src/search-modal/__mocks__/facet-search-level1.json new file mode 100644 index 0000000000..f6a68e3336 --- /dev/null +++ b/src/search-modal/__mocks__/facet-search-level1.json @@ -0,0 +1,8 @@ +{ + "facetHits": [ + { "value": "ESDC Skills and Competencies > Abilities > Cognitive Abilities", "count": 3 }, + { "value": "ESDC Skills and Competencies > Abilities > Physical Abilities", "count": 2 } + ], + "facetQuery": "", + "processingTimeMs": 0 +} diff --git a/src/search-modal/__mocks__/facet-search.json b/src/search-modal/__mocks__/facet-search.json new file mode 100644 index 0000000000..71db55107e --- /dev/null +++ b/src/search-modal/__mocks__/facet-search.json @@ -0,0 +1,12 @@ +{ + "facetHits": [ + { "value": "ESDC Skills and Competencies", "count": 7 }, + { "value": "FlatTaxonomy", "count": 7 }, + { "value": "HierarchicalTaxonomy", "count": 6 }, + { "value": "Lightcast Open Skills Taxonomy", "count": 6 }, + { "value": "MultiOrgTaxonomy", "count": 7 }, + { "value": "TwoLevelTaxonomy", "count": 7 } + ], + "facetQuery": "", + "processingTimeMs": 0 +} diff --git a/src/search-modal/__mocks__/search-result.json b/src/search-modal/__mocks__/search-result.json index 234bb10c4e..ff4397a406 100644 --- a/src/search-modal/__mocks__/search-result.json +++ b/src/search-modal/__mocks__/search-result.json @@ -74,21 +74,22 @@ "processingTimeMs": 1, "limit": 2, "offset": 0, - "estimatedTotalHits": 2, + "estimatedTotalHits": 2 + }, + { + "indexUid": "studio", + "hits": [], + "query": "learn", + "processingTimeMs": 1, + "limit": 0, + "offset": 0, + "estimatedTotalHits": 0, "facetDistribution": { "block_type": { "html": 2, "problem": 16, "vertical": 2, "video": 1 - }, - "tags.taxonomy": { - "ESDC Skills and Competencies": 1, - "FlatTaxonomy": 2, - "HierarchicalTaxonomy": 1, - "Lightcast Open Skills Taxonomy": 1, - "MultiOrgTaxonomy": 1, - "TwoLevelTaxonomy": 2 } }, "facetStats": {} diff --git a/src/search-modal/__mocks__/tags-keyword-search.json b/src/search-modal/__mocks__/tags-keyword-search.json new file mode 100644 index 0000000000..51be970963 --- /dev/null +++ b/src/search-modal/__mocks__/tags-keyword-search.json @@ -0,0 +1,48 @@ +{ + "comment": "Because this document has at least one tag that matches the search 'lightcast', all of its tags get returned.", + "hits": [ + { + "tags": { + "taxonomy": [ + "ESDC Skills and Competencies", + "FlatTaxonomy", + "HierarchicalTaxonomy", + "Lightcast Open Skills Taxonomy" + ], + "level0": [ + "ESDC Skills and Competencies > Interests", + "FlatTaxonomy > flat taxonomy tag 1420", + "FlatTaxonomy > flat taxonomy tag 1683", + "FlatTaxonomy > flat taxonomy tag 2633", + "HierarchicalTaxonomy > hierarchical taxonomy tag 1", + "HierarchicalTaxonomy > hierarchical taxonomy tag 2", + "HierarchicalTaxonomy > hierarchical taxonomy tag 4", + "Lightcast Open Skills Taxonomy > Information Technology Category" + ], + "level1": [ + "ESDC Skills and Competencies > Interests > Holland Codes", + "HierarchicalTaxonomy > hierarchical taxonomy tag 1 > hierarchical taxonomy tag 1.3", + "HierarchicalTaxonomy > hierarchical taxonomy tag 2 > hierarchical taxonomy tag 2.16", + "HierarchicalTaxonomy > hierarchical taxonomy tag 4 > hierarchical taxonomy tag 4.8", + "Lightcast Open Skills Taxonomy > Information Technology Category > Web Content" + ], + "level2": [ + "ESDC Skills and Competencies > Interests > Holland Codes > Interests - Holland Codes", + "HierarchicalTaxonomy > hierarchical taxonomy tag 1 > hierarchical taxonomy tag 1.3 > hierarchical taxonomy tag 1.3.7", + "HierarchicalTaxonomy > hierarchical taxonomy tag 2 > hierarchical taxonomy tag 2.16 > hierarchical taxonomy tag 2.16.31", + "HierarchicalTaxonomy > hierarchical taxonomy tag 4 > hierarchical taxonomy tag 4.8 > hierarchical taxonomy tag 4.8.25", + "Lightcast Open Skills Taxonomy > Information Technology Category > Web Content > Web Resource" + ], + "level3": [ + "ESDC Skills and Competencies > Interests > Holland Codes > Interests - Holland Codes > Artistic", + "ESDC Skills and Competencies > Interests > Holland Codes > Interests - Holland Codes > Investigative" + ] + } + } + ], + "query": "lightcast", + "processingTimeMs": 3, + "limit": 1000, + "offset": 0, + "estimatedTotalHits": 23 +} diff --git a/src/search-modal/data/api.js b/src/search-modal/data/api.js index ce306ee49e..126df5248f 100644 --- a/src/search-modal/data/api.js +++ b/src/search-modal/data/api.js @@ -1,5 +1,5 @@ // @ts-check -import { getConfig } from '@edx/frontend-platform'; +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; export const getContentSearchConfigUrl = () => new URL( @@ -7,6 +7,12 @@ export const getContentSearchConfigUrl = () => new URL( getConfig().STUDIO_BASE_URL, ).href; +/** The separator used for hierarchical tags in the search index, e.g. tags.level1 = "Subject > Math > Calculus" */ +export const TAG_SEP = ' > '; + +export const highlightPreTag = '__meili-highlight__'; // Indicate the start of a highlighted (matching) term +export const highlightPostTag = '__/meili-highlight__'; // Indicate the end of a highlighted (matching) term + /** * Get the content search configuration from the CMS. * @@ -21,3 +27,361 @@ export const getContentSearchConfig = async () => { apiKey: response.data.api_key, }; }; + +/** + * Detailed "content" of an XBlock/component, from the block's index_dictionary function. Contents depends on the type. + * @typedef {{htmlContent?: string, capaContent?: string, [k: string]: any}} ContentDetails + */ + +/** + * Meilisearch filters can be expressed as strings or arrays. + * This helper method converts from any supported input format to an array, for consistency. + * @param {import('meilisearch').Filter} [filter] A filter expression, e.g. 'foo = bar' or [['a = b', 'a = c'], 'd = e'] + * @returns {(string | string[])[]} + */ +function forceArray(filter) { + if (typeof filter === 'string') { + return [filter]; + } + if (filter === undefined) { + return []; + } + return filter; +} + +/** + * Given tag paths like ["Difficulty > Hard", "Subject > Math"], convert them to an array of Meilisearch + * filter conditions. The tag filters are all AND conditions (not OR). + * @param {string[]} [tagsFilter] e.g. ["Difficulty > Hard", "Subject > Math"] + * @returns {string[]} + */ +function formatTagsFilter(tagsFilter) { + /** @type {string[]} */ + const filters = []; + + tagsFilter?.forEach((tagPath) => { + const parts = tagPath.split(TAG_SEP); + if (parts.length === 1) { + filters.push(`tags.taxonomy = "${tagPath}"`); + } else { + filters.push(`tags.level${parts.length - 2} = "${tagPath}"`); + } + }); + + return filters; +} + +/** + * Information about a single XBlock returned in the search results + * Defined in edx-platform/openedx/core/djangoapps/content/search/documents.py + * @typedef {Object} ContentHit + * @property {string} id + * @property {string} usageKey + * @property {"course_block"|"library_block"} type + * @property {string} blockId + * @property {string} displayName + * @property {string} blockType The block_type part of the usage key. What type of XBlock this is. + * @property {string} contextKey The course or library ID + * @property {string} org + * @property {{displayName: string}[]} breadcrumbs First one is the name of the course/library itself. + * After that is the name 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 + */ + +/** + * Convert search hits to camelCase + * @param {Record} hit A search result directly from Meilisearch + * @returns {ContentHit} + */ +function formatSearchHit(hit) { + const { _formatted, ...newHit } = hit; + newHit.formatted = { + displayName: _formatted.display_name, + content: _formatted.content ?? {}, + }; + return camelCaseObject(newHit); +} + +/** + * @param {{ + * client: import('meilisearch').MeiliSearch, + * indexName: string, + * searchKeywords: string, + * blockTypesFilter?: string[], + * tagsFilter?: string[], + * extraFilter?: import('meilisearch').Filter, + * offset?: number, + * }} context + * @returns {Promise<{ + * hits: ContentHit[], + * nextOffset: number|undefined, + * totalHits: number, + * blockTypes: Record, + * }>} + */ +export async function fetchSearchResults({ + client, + indexName, + searchKeywords, + blockTypesFilter, + /** The full path of tags that each result MUST have, e.g. ["Difficulty > Hard", "Subject > Math"] */ + tagsFilter, + extraFilter, + /** How many results to skip, e.g. if limit=20 then passing offset=20 gets the second page. */ + offset = 0, +}) { + /** @type {import('meilisearch').MultiSearchQuery[]} */ + const queries = []; + + // Convert 'extraFilter' into an array + const extraFilterFormatted = forceArray(extraFilter); + + const blockTypesFilterFormatted = blockTypesFilter?.length ? [blockTypesFilter.map(bt => `block_type = ${bt}`)] : []; + + const tagsFilterFormatted = formatTagsFilter(tagsFilter); + + const limit = 20; // How many results to retrieve per page. + + // First query is always to get the hits, with all the filters applied. + queries.push({ + indexUid: indexName, + q: searchKeywords, + filter: [ + // top-level entries in the array are AND conditions and must all match + // Inner arrays are OR conditions, where only one needs to match. + ...extraFilterFormatted, + ...blockTypesFilterFormatted, + ...tagsFilterFormatted, + ], + attributesToHighlight: ['display_name', 'content'], + highlightPreTag, + highlightPostTag, + attributesToCrop: ['content'], + cropLength: 20, + offset, + limit, + }); + + // The second query is to get the possible values for the "block types" filter + queries.push({ + indexUid: indexName, + q: searchKeywords, + facets: ['block_type'], + filter: [ + ...extraFilterFormatted, + // We exclude the block type filter here so we get all the other available options for it. + ...tagsFilterFormatted, + ], + limit: 0, // We don't need any "hits" for this - just the facetDistribution + }); + + const { results } = await client.multiSearch(({ queries })); + return { + hits: results[0].hits.map(formatSearchHit), + totalHits: results[0].totalHits ?? results[0].estimatedTotalHits ?? results[0].hits.length, + blockTypes: results[1].facetDistribution?.block_type ?? {}, + nextOffset: results[0].hits.length === limit ? offset + limit : undefined, + }; +} + +/** + * In the context of a particular search (which may already be filtered to a specific course, specific block types, + * and/or have a keyword search applied), get the tree of tags that can be used to further filter/refine the search. + * + * @param {object} context + * @param {import('meilisearch').MeiliSearch} context.client The Meilisearch client instance + * @param {string} context.indexName Which index to search + * @param {string} context.searchKeywords Overall query string for the search; may be empty + * @param {string[]} [context.blockTypesFilter] Filter to only include these block types e.g. ["problem", "html"] + * @param {import('meilisearch').Filter} [context.extraFilter] Any other filters to apply, e.g. course ID. + * @param {string} [context.parentTagPath] Only fetch tags below this parent tag/taxonomy e.g. "Places > North America" + * @returns {Promise<{ + * tags: {tagName: string, tagPath: string, tagCount: number, hasChildren: boolean}[]; + * mayBeMissingResults: boolean; + * }>} + */ +export async function fetchAvailableTagOptions({ + client, + indexName, + searchKeywords, + blockTypesFilter, + extraFilter, + parentTagPath, + // Ideally this would include 'tagSearchKeywords' to filter the tag tree by keyword search but that's not possible yet +}) { + const meilisearchFacetLimit = 100; // The 'maxValuesPerFacet' on the index. For Open edX we leave the default, 100. + + // Convert 'extraFilter' into an array + const extraFilterFormatted = forceArray(extraFilter); + + const blockTypesFilterFormatted = blockTypesFilter?.length ? [blockTypesFilter.map(bt => `block_type = ${bt}`)] : []; + + // Figure out which "facet" (attribute of the documents in the search index) holds the tags at the level we want. + // e.g. "tags.taxonomy" is the facet/attribute that holds the root tags, and "tags.level0" has its child tags. + let facetName; + let depth; + /** @type {string[]} */ + let parentFilter = []; + if (!parentTagPath) { + facetName = 'tags.taxonomy'; + depth = 0; + } else { + const parentParts = parentTagPath.split(TAG_SEP); + depth = parentParts.length; + facetName = `tags.level${depth - 1}`; + const parentFacetName = parentParts.length === 1 ? 'tags.taxonomy' : `tags.level${parentParts.length - 2}`; + parentFilter = [`${parentFacetName} = "${parentTagPath}"`]; + } + + // As an optimization, start pre-loading the data about "has child tags", if we will need it later. + // Notice we don't 'await' the result of this request, so it can happen in parallel with the main request that follows + const maybeHasChildren = depth > 0 && depth < 4; // If depth=0, it definitely has children; we don't support depth > 4 + const nextLevelFacet = `tags.level${depth}`; // This will give the children of the current tags. + const preloadChildTagsData = maybeHasChildren ? client.index(indexName).searchForFacetValues({ + facetName: nextLevelFacet, + facetQuery: parentTagPath, + q: searchKeywords, + filter: [...extraFilterFormatted, ...blockTypesFilterFormatted, ...parentFilter], + }) : undefined; + + // Now load the facet values. Doing it with this API gives us much more flexibility in loading than if we just + // requested the facets by passing { facets: ["tags"] } into the main search request; that works fine for loading the + // root tags but can't load specific child tags like we can using this approach. + /** @type {{tagName: string, tagPath: string, tagCount: number, hasChildren: boolean}[]} */ + const tags = []; + const { facetHits } = await client.index(indexName).searchForFacetValues({ + facetName, + // It's not super clear in the documentation, but facetQuery is basically a "startsWith" query, which is what we + // need here to return just the tags below the selected parent tag. However, it's a fuzzy query that may match + // more tags than we want it to, so we have to explicitly post-process and reduce the set of results using an + // exact match. + facetQuery: parentTagPath, + q: searchKeywords, + filter: [...extraFilterFormatted, ...blockTypesFilterFormatted, ...parentFilter], + }); + facetHits.forEach(({ value: tagPath, count: tagCount }) => { + if (!parentTagPath) { + tags.push({ + tagName: tagPath, + tagPath, + tagCount, + hasChildren: true, // You can't tag something with just a taxonomy, so this definitely has child tags. + }); + } else { + const parts = tagPath.split(TAG_SEP); + const tagName = parts[parts.length - 1]; + if (tagPath === `${parentTagPath}${TAG_SEP}${tagName}`) { + tags.push({ + tagName, + tagPath, + tagCount, + hasChildren: false, // We'll set this later + }); + } // Else this is a tag from another taxonomy/parent that was included because this search is "fuzzy". Ignore it. + } + }); + + // Figure out if [some of] the tags at this level have children: + if (maybeHasChildren) { + if (preloadChildTagsData === undefined) { throw new Error('Child tags data unexpectedly not pre-loaded'); } + // Retrieve the children of the current tags: + const { facetHits: childFacetHits } = await preloadChildTagsData; + if (childFacetHits.length >= meilisearchFacetLimit) { + // Assume they all have child tags; we can't retrieve more than 100 facet values (per Meilisearch docs) so + // we can't say for sure on a tag-by-tag basis, but we know that at least some of them have children, so + // it's a safe bet that most/all of them have children. And it's not a huge problem if we say they have children + // but they don't. + // eslint-disable-next-line no-param-reassign + tags.forEach((t) => { t.hasChildren = true; }); + } else if (childFacetHits.length > 0) { + // Some (or maybe all) of these tags have child tags. Let's figure out which ones exactly. + /** @type {Set} */ + const tagsWithChildren = new Set(); + childFacetHits.forEach(({ value }) => { + // Trim the child tag off: 'Places > North America > New York' becomes 'Places > North America' + const tagPath = value.split(TAG_SEP).slice(0, -1).join(TAG_SEP); + tagsWithChildren.add(tagPath); + }); + // eslint-disable-next-line no-param-reassign + tags.forEach((t) => { t.hasChildren = tagsWithChildren.has(t.tagPath); }); + } + } + + // If we hit the limit of facetHits, there are probably even more tags, but there is no API to retrieve + // them (no pagination etc.), so just tell the user that not all tags could be displayed. This should be pretty rare. + return { tags, mayBeMissingResults: facetHits.length >= meilisearchFacetLimit }; +} + +/** + * Best-effort search for *all* tags among the search results (with filters applied) that contain the given keyword. + * + * Unfortunately there is no good Meilisearch API for this, so we just have to do the best we can. If more than 1,000 + * objects are tagged with matching tags, this will be an incomplete result. For example, if 1,000 XBlocks/components + * are tagged with "Tag Alpha 1" and 10 XBlocks are tagged with "Tag Alpha 2", a search for "Alpha" may only return + * ["Tag Alpha 1"] instead of the correct result ["Tag Alpha 1", "Tag Alpha 2"] because we are limited to 1,000 matches, + * which may all have the same tags. + * + * @param {object} context + * @param {import('meilisearch').MeiliSearch} context.client The Meilisearch client instance + * @param {string} context.indexName Which index to search + * @param {string[]} [context.blockTypesFilter] Filter to only include these block types e.g. ["problem", "html"] + * @param {import('meilisearch').Filter} [context.extraFilter] Any other filters to apply to the overall search. + * @param {string} [context.tagSearchKeywords] Only show taxonomies/tags that match these keywords + * @returns {Promise<{ mayBeMissingResults: boolean; matches: {tagPath: string}[] }>} + */ +export async function fetchTagsThatMatchKeyword({ + client, + indexName, + blockTypesFilter, + extraFilter, + tagSearchKeywords, +}) { + if (!tagSearchKeywords || tagSearchKeywords.trim() === '') { + // This data isn't needed if there is no tag keyword search. Don't bother making a search query. + return { matches: [], mayBeMissingResults: false }; + } + // Convert 'extraFilter' into an array + const extraFilterFormatted = forceArray(extraFilter); + + const blockTypesFilterFormatted = blockTypesFilter?.length ? [blockTypesFilter.map(bt => `block_type = ${bt}`)] : []; + + const limit = 1000; // This is the most results we can retrieve in a single query. + + // We search for any matches of the keyword in the "tags" field, respecting the current filters like block type filter + // or current course filter. (Unfortunately we cannot also include the overall `searchKeywords` so this will match + // against more content than it should.) + const { hits } = await client.index(indexName).search(tagSearchKeywords, { + filter: [...extraFilterFormatted, ...blockTypesFilterFormatted], + attributesToSearchOn: ['tags.taxonomy', 'tags.level0', 'tags.level1', 'tags.level2', 'tags.level3'], + attributesToRetrieve: ['tags'], + limit, + // We'd like to use 'showMatchesPosition: true' to know exaclty which tags match, but it doesn't provide the + // detail we need; it's impossible to tell which tag at a given level matched based on the returned _matchesPosition + // data - https://github.com/orgs/meilisearch/discussions/550 + }); + + const tagSearchKeywordsLower = tagSearchKeywords.toLocaleLowerCase(); + + /** @type {Set} */ + const matches = new Set(); + + // We have data like this: + // hits: [ + // { + // tags: { taxonomy: "Competency", "level0": "Competency > Abilities", "level1": "Competency > Abilities > ..." }, + // }, ... + // ] + hits.forEach((hit) => { + Object.values(hit.tags).forEach((tagPathList) => { + tagPathList.forEach((tagPath) => { + if (tagPath.toLocaleLowerCase().includes(tagSearchKeywordsLower)) { + matches.add(tagPath); + } + }); + }); + }); + + return { matches: Array.from(matches).map((tagPath) => ({ tagPath })), mayBeMissingResults: hits.length === limit }; +} diff --git a/src/search-modal/data/apiHooks.js b/src/search-modal/data/apiHooks.js index 36e5c2e12f..02488635da 100644 --- a/src/search-modal/data/apiHooks.js +++ b/src/search-modal/data/apiHooks.js @@ -1,16 +1,21 @@ // @ts-check +import React from 'react'; +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; -import { useQuery } from '@tanstack/react-query'; - -import { getContentSearchConfig } from './api'; +import { + TAG_SEP, + fetchAvailableTagOptions, + fetchSearchResults, + fetchTagsThatMatchKeyword, + getContentSearchConfig, +} from './api'; /** - * Load the Meilisearch connection details from the CMS: the URL to use, the index name, and an API key specific - * to the current user that allows it to search all content he have permission to view. - * - */ -/* eslint-disable import/prefer-default-export */ -export const useContentSearch = () => ( + * Load the Meilisearch connection details from the CMS: the URL to use, the index name, and an API key specific + * to the current user that allows it to search all content he have permission to view. + * + */ +export const useContentSearchConnection = () => ( useQuery({ queryKey: ['content_search'], queryFn: getContentSearchConfig, @@ -21,3 +26,166 @@ export const useContentSearch = () => ( refetchOnMount: false, }) ); + +/** + * Get the results of a search + * @param {object} context + * @param {import('meilisearch').MeiliSearch} [context.client] The Meilisearch API client + * @param {string} [context.indexName] Which search index contains the content data + * @param {import('meilisearch').Filter} [context.extraFilter] Other filters to apply to the search, e.g. course ID + * @param {string} context.searchKeywords The keywords that the user is searching for, if any + * @param {string[]} context.blockTypesFilter Only search for these block types (e.g. ["html", "problem"]) + * @param {string[]} context.tagsFilter Required tags (all must match), e.g. ["Difficulty > Hard", "Subject > Math"] + */ +export const useContentSearchResults = ({ + client, + indexName, + extraFilter, + searchKeywords, + blockTypesFilter, + tagsFilter, +}) => { + const query = useInfiniteQuery({ + enabled: client !== undefined && indexName !== undefined, + queryKey: [ + 'content_search', + 'results', + client?.config.apiKey, + client?.config.host, + indexName, + extraFilter, + searchKeywords, + blockTypesFilter, + tagsFilter, + ], + queryFn: ({ pageParam = 0 }) => { + if (client === undefined || indexName === undefined) { + throw new Error('Required data unexpectedly undefined. Check "enable" condition of useQuery.'); + } + return fetchSearchResults({ + client, + extraFilter, + indexName, + searchKeywords, + blockTypesFilter, + tagsFilter, + // For infinite pagination of results, we can retrieve additional pages if requested. + // Note that if there are 20 results per page, the "second page" has offset=20, not 2. + offset: pageParam, + }); + }, + getNextPageParam: (lastPage) => lastPage.nextOffset, + // Avoid flickering results when user is typing... keep old results until new is available. + keepPreviousData: true, + refetchOnWindowFocus: false, // This doesn't need to be refreshed when the user switches back to this tab. + }); + + const pages = query.data?.pages; + const hits = React.useMemo( + () => pages?.reduce((allHits, page) => [...allHits, ...page.hits], []) ?? [], + [pages], + ); + + return { + hits, + // The distribution of block type filter options + blockTypes: pages?.[0]?.blockTypes ?? {}, + status: query.status, + isFetching: query.isFetching, + isError: query.isError, + isFetchingNextPage: query.isFetchingNextPage, + // Call this to load more pages. We include some "safety" features recommended by the docs: this should never be + // called while already fetching a page, and parameters (like 'event') should not be passed into fetchNextPage(). + // See https://tanstack.com/query/v4/docs/framework/react/guides/infinite-queries + fetchNextPage: () => { if (!query.isFetching && !query.isFetchingNextPage) { query.fetchNextPage(); } }, + hasNextPage: query.hasNextPage, + // The last page has the most accurate count of total hits + totalHits: pages?.[pages.length - 1]?.totalHits ?? 0, + }; +}; + +/** + * Get the available tags that can be used to refine a search, based on the search filters applied so far. + * Also the user can use a keyword search to find specific tags. + * @param {object} args + * @param {import('meilisearch').MeiliSearch} [args.client] The Meilisearch client instance + * @param {string} [args.indexName] Which index to search + * @param {string} args.searchKeywords Overall query string for the search; may be empty + * @param {string[]} [args.blockTypesFilter] Filter to only include these block types e.g. ["problem", "html"] + * @param {import('meilisearch').Filter} [args.extraFilter] Any other filters to apply to the overall search. + * @param {string} [args.tagSearchKeywords] Only show taxonomies/tags that match these keywords + * @param {string} [args.parentTagPath] Only fetch tags below this parent tag/taxonomy e.g. "Places > North America" + */ +export const useTagFilterOptions = (args) => { + const mainQuery = useQuery({ + enabled: args.client !== undefined && args.indexName !== undefined, + queryKey: [ + 'content_search', + 'tag_filter_options', + args.client?.config.apiKey, + args.client?.config.host, + args.indexName, + args.extraFilter, + args.searchKeywords, + args.blockTypesFilter, + args.parentTagPath, + ], + queryFn: () => { + const { client, indexName } = args; + if (client === undefined || indexName === undefined) { + throw new Error('Required data unexpectedly undefined. Check "enable" condition of useQuery.'); + } + return fetchAvailableTagOptions({ ...args, client, indexName }); + }, + // Avoid flickering results when user is typing... keep old results until new is available. + keepPreviousData: true, + refetchOnWindowFocus: false, // This doesn't need to be refreshed when the user switches back to this tab. + }); + + const tagKeywordSearchData = useQuery({ + enabled: args.client !== undefined && args.indexName !== undefined, + queryKey: [ + 'content_search', + 'tags_keyword_search_data', + args.client?.config.apiKey, + args.client?.config.host, + args.indexName, + args.extraFilter, + args.blockTypesFilter, + args.tagSearchKeywords, + ], + queryFn: () => { + const { client, indexName } = args; + if (client === undefined || indexName === undefined) { + throw new Error('Required data unexpectedly undefined. Check "enable" condition of useQuery.'); + } + return fetchTagsThatMatchKeyword({ ...args, client, indexName }); + }, + // Avoid flickering results when user is typing... keep old results until new is available. + keepPreviousData: true, + refetchOnWindowFocus: false, // This doesn't need to be refreshed when the user switches back to this tab. + }); + + const data = React.useMemo(() => { + if (!args.tagSearchKeywords || !tagKeywordSearchData.data) { + // If there's no keyword search being used to filter the list of available tags, just use the results of the + // main query. + return { tags: mainQuery.data?.tags, mayBeMissingResults: mainQuery.data?.mayBeMissingResults ?? false }; + } + if (mainQuery.data === undefined) { + return { tags: undefined, mayBeMissingResults: false }; + } + // Combine these two queries to filter the list of tags based on the keyword search. + const tags = mainQuery.data.tags.filter( + ({ tagPath }) => tagKeywordSearchData.data.matches.some( + (matchingTag) => matchingTag.tagPath === tagPath || matchingTag.tagPath.startsWith(tagPath + TAG_SEP), + ), + ); + return { + tags, + mayBeMissingResults: mainQuery.data.mayBeMissingResults || tagKeywordSearchData.data.mayBeMissingResults, + }; + }, [mainQuery.data, tagKeywordSearchData.data]); + + return { ...mainQuery, data }; +}; diff --git a/src/search-modal/manager/SearchManager.js b/src/search-modal/manager/SearchManager.js new file mode 100644 index 0000000000..bbed2e08e5 --- /dev/null +++ b/src/search-modal/manager/SearchManager.js @@ -0,0 +1,104 @@ +/* eslint-disable react/prop-types */ +// @ts-check +/** + * This is a search manager that provides search functionality similar to the + * Instantsearch library. We use it because Instantsearch doesn't support + * multiple selections of hierarchical tags. + * https://github.com/algolia/instantsearch/issues/1658 + */ +import React from 'react'; +import { MeiliSearch } from 'meilisearch'; + +import { useContentSearchConnection, useContentSearchResults } from '../data/apiHooks'; + +/** + * @type {React.Context>, + * blockTypesFilter: string[], + * setBlockTypesFilter: React.Dispatch>, + * tagsFilter: string[], + * setTagsFilter: React.Dispatch>, + * blockTypes: Record, + * extraFilter?: import('meilisearch').Filter, + * canClearFilters: boolean, + * clearFilters: () => void, + * hits: import('../data/api').ContentHit[], + * totalHits: number, + * isFetching: boolean, + * hasNextPage: boolean | undefined, + * isFetchingNextPage: boolean, + * fetchNextPage: () => void, + * closeSearchModal: () => void, + * hasError: boolean, + * }>} + */ +const SearchContext = /** @type {any} */(React.createContext(undefined)); + +/** + * @type {React.FC<{ +* extraFilter?: import('meilisearch').Filter, +* children: React.ReactNode, +* closeSearchModal?: () => void, +* }>} +*/ +export const SearchContextProvider = ({ extraFilter, children, closeSearchModal }) => { + const [searchKeywords, setSearchKeywords] = React.useState(''); + const [blockTypesFilter, setBlockTypesFilter] = React.useState(/** type {string[]} */([])); + const [tagsFilter, setTagsFilter] = React.useState(/** type {string[]} */([])); + + const canClearFilters = blockTypesFilter.length > 0 || tagsFilter.length > 0; + const clearFilters = React.useCallback(() => { + setBlockTypesFilter([]); + setTagsFilter([]); + }, []); + + // Initialize a connection to Meilisearch: + const { data: connectionDetails, isError: hasConnectionError } = useContentSearchConnection(); + const indexName = connectionDetails?.indexName; + const client = React.useMemo(() => { + if (connectionDetails?.apiKey === undefined || connectionDetails?.url === undefined) { + return undefined; + } + return new MeiliSearch({ host: connectionDetails.url, apiKey: connectionDetails.apiKey }); + }, [connectionDetails?.apiKey, connectionDetails?.url]); + + // Run the search + const result = useContentSearchResults({ + client, + indexName, + extraFilter, + searchKeywords, + blockTypesFilter, + tagsFilter, + }); + + return React.createElement(SearchContext.Provider, { + value: { + client, + indexName, + searchKeywords, + setSearchKeywords, + blockTypesFilter, + setBlockTypesFilter, + tagsFilter, + setTagsFilter, + extraFilter, + canClearFilters, + clearFilters, + closeSearchModal: closeSearchModal ?? (() => {}), + hasError: hasConnectionError || result.isError, + ...result, + }, + }, children); +}; + +export const useSearchContext = () => { + const ctx = React.useContext(SearchContext); + if (ctx === undefined) { + throw new Error('Cannot use search components outside of '); + } + return ctx; +}; diff --git a/src/search-modal/messages.js b/src/search-modal/messages.js index ad429af29d..d72a5b6b78 100644 --- a/src/search-modal/messages.js +++ b/src/search-modal/messages.js @@ -25,6 +25,16 @@ const messages = defineMessages({ defaultMessage: 'No tags in current results', description: 'Label shown when there are no options available to filter by tags', }, + 'blockTagsFilter.error': { + id: 'course-authoring.course-search.blockTagsFilter.error', + defaultMessage: 'Error loading tags', + description: 'Label shown when the tags could not be loaded', + }, + 'blockTagsFilter.incomplete': { + id: 'course-authoring.course-search.blockTagsFilter.incomplete', + defaultMessage: 'Sorry, not all tags could be loaded', + description: 'Label shown when the system is not able to display all of the available tag options.', + }, 'blockType.annotatable': { id: 'course-authoring.course-search.blockType.annotatable', defaultMessage: 'Annotation', @@ -80,6 +90,16 @@ const messages = defineMessages({ defaultMessage: 'Video', description: 'Name of the "Video" component type in Studio', }, + childTagsExpand: { + id: 'course-authoring.course-search.child-tags-expand', + defaultMessage: 'Expand to show child tags of "{tagName}"', + description: 'This text describes the ▼ expand toggle button to non-visual users.', + }, + childTagsCollapse: { + id: 'course-authoring.course-search.child-tags-collapse', + defaultMessage: 'Collapse to hide child tags of "{tagName}"', + description: 'This text describes the ▲ collapse toggle button to non-visual users.', + }, clearFilters: { id: 'course-authoring.course-search.clearFilters', defaultMessage: 'Clear Filters', @@ -110,11 +130,31 @@ const messages = defineMessages({ defaultMessage: 'Search', description: 'Placeholder text shown in the keyword input field when the user has not yet entered a keyword', }, + searchTagsByKeywordPlaceholder: { + id: 'course-authoring.course-search.searchTagsByKeywordPlaceholder', + defaultMessage: 'Search tags', + description: 'Placeholder text shown in the input field that allows searching through the available tags', + }, + submitSearchTagsByKeyword: { + id: 'course-authoring.course-search.submitSearchTagsByKeyword', + defaultMessage: 'Submit tag keyword search', + description: 'Text shown to screen reader users for the search button on the tags keyword search', + }, showMore: { id: 'course-authoring.course-search.showMore', defaultMessage: 'Show more', description: 'Show more tags / filter options', }, + showMoreResults: { + id: 'course-authoring.course-search.showMoreResults', + defaultMessage: 'Show more results', + description: 'Show more results - a button to add to the list of results by loading more from the server', + }, + loadingMoreResults: { + id: 'course-authoring.course-search.loadingMoreResults', + defaultMessage: 'Loading more results', + description: 'Loading more results - the button displays this message while more results are loading', + }, emptySearchTitle: { id: 'course-authoring.course-search.emptySearchTitle', defaultMessage: 'Start searching to find content', @@ -135,16 +175,16 @@ const messages = defineMessages({ defaultMessage: 'Please try a different search term or filter', description: 'Subtitle shown when the search returned no results', }, - showMoreResults: { - id: 'course-authoring.course-search.showMoreResults', - defaultMessage: 'Show more results', - description: 'Show more results button label', - }, openInNewWindow: { id: 'course-authoring.course-search.openInNewWindow', defaultMessage: 'Open in new window', description: 'Alt text for the button that opens the search result in a new window', }, + searchError: { + id: 'course-authoring.course-search.searchError', + defaultMessage: 'An error occurred. Unable to load search results.', + description: 'Error message shown when search is not working.', + }, }); export default messages;