diff --git a/package-lock.json b/package-lock.json index 40bb2186ec..0e67189716 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6618,9 +6618,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001662", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001662.tgz", - "integrity": "sha512-sgMUVwLmGseH8ZIrm1d51UbrhqMCH3jvS7gF/M6byuHOnKyLOBL7W8yz5V02OHwgLGA36o/AFhWzzh4uc5aqTA==", + "version": "1.0.30001664", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001664.tgz", + "integrity": "sha512-AmE7k4dXiNKQipgn7a2xg558IRqPN3jMQY/rOsbxDhrd0tyChwbITBfiwtnqz8bi2M5mIWbxAYBvk7W7QBUS2g==", "funding": [ { "type": "opencollective", diff --git a/plugins/course-apps/ora_settings/Settings.jsx b/plugins/course-apps/ora_settings/Settings.jsx index b3e3c0d287..f16d10f48b 100644 --- a/plugins/course-apps/ora_settings/Settings.jsx +++ b/plugins/course-apps/ora_settings/Settings.jsx @@ -1,69 +1,176 @@ -import React from 'react'; +import { useEffect, useState, useRef } from 'react'; import PropTypes from 'prop-types'; -import * as Yup from 'yup'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { useDispatch, useSelector } from 'react-redux'; -import { Hyperlink } from '@openedx/paragon'; -import { useModel } from 'CourseAuthoring/generic/model-store'; +import { + ActionRow, Alert, Badge, Form, Hyperlink, ModalDialog, StatefulButton, +} from '@openedx/paragon'; +import { Info } from '@openedx/paragon/icons'; +import { updateModel, useModel } from 'CourseAuthoring/generic/model-store'; +import { RequestStatus } from 'CourseAuthoring/data/constants'; import FormSwitchGroup from 'CourseAuthoring/generic/FormSwitchGroup'; -import { useAppSetting } from 'CourseAuthoring/utils'; -import AppSettingsModal from 'CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal'; +import Loading from 'CourseAuthoring/generic/Loading'; +import PermissionDeniedAlert from 'CourseAuthoring/generic/PermissionDeniedAlert'; +import ConnectionErrorAlert from 'CourseAuthoring/generic/ConnectionErrorAlert'; +import { useAppSetting, useIsMobile } from 'CourseAuthoring/utils'; +import { getLoadingStatus, getSavingStatus } from 'CourseAuthoring/pages-and-resources/data/selectors'; +import { updateSavingStatus } from 'CourseAuthoring/pages-and-resources/data/slice'; + import messages from './messages'; -const ORASettings = ({ intl, onClose }) => { +const ORASettings = ({ onClose }) => { + const dispatch = useDispatch(); + const { formatMessage } = useIntl(); + const alertRef = useRef(null); + const updateSettingsRequestStatus = useSelector(getSavingStatus); + const loadingStatus = useSelector(getLoadingStatus); + const isMobile = useIsMobile(); + const modalVariant = isMobile ? 'dark' : 'default'; const appId = 'ora_settings'; const appInfo = useModel('courseApps', appId); + const [enableFlexiblePeerGrade, saveSetting] = useAppSetting( 'forceOnFlexiblePeerOpenassessments', ); + const initialFormValues = { enableFlexiblePeerGrade }; + + const [formValues, setFormValues] = useState(initialFormValues); + const [saveError, setSaveError] = useState(false); + + const submitButtonState = updateSettingsRequestStatus === RequestStatus.IN_PROGRESS ? 'pending' : 'default'; const handleSettingsSave = (values) => saveSetting(values.enableFlexiblePeerGrade); - const title = ( -
-

{intl.formatMessage(messages.heading)}

-
- - {intl.formatMessage(messages.ORASettingsHelpLink)} - -
-
- ); + const handleSubmit = async (event) => { + let success = true; + event.preventDefault(); + + success = success && await handleSettingsSave(formValues); + await setSaveError(!success); + if ((initialFormValues.enableFlexiblePeerGrade !== formValues.enableFlexiblePeerGrade) && success) { + success = await dispatch(updateModel({ + modelType: 'courseApps', + model: { + id: appId, enabled: formValues.enableFlexiblePeerGrade, + }, + })); + } + !success && alertRef?.current.scrollIntoView(); // eslint-disable-line @typescript-eslint/no-unused-expressions + }; + + const handleChange = (e) => { + setFormValues({ enableFlexiblePeerGrade: e.target.checked }); + }; + + useEffect(() => { + if (updateSettingsRequestStatus === RequestStatus.SUCCESSFUL) { + dispatch(updateSavingStatus({ status: '' })); + onClose(); + } + }, [updateSettingsRequestStatus]); + + const renderBody = () => { + switch (loadingStatus) { + case RequestStatus.SUCCESSFUL: + return ( + <> + {saveError && ( + + + {formatMessage(messages.errorSavingTitle)} + + {formatMessage(messages.errorSavingMessage)} + + )} + + {formatMessage(messages.enableFlexPeerGradeLabel)} + {formValues.enableFlexiblePeerGrade && ( + + {formatMessage(messages.enabledBadgeLabel)} + + )} + + )} + helpText={( +
+

{formatMessage(messages.enableFlexPeerGradeHelp)}

+ + + {formatMessage(messages.ORASettingsHelpLink)} + + +
+ )} + onChange={handleChange} + checked={formValues.enableFlexiblePeerGrade} + /> + + ); + case RequestStatus.DENIED: + return ; + case RequestStatus.FAILED: + return ; + default: + return ; + } + }; return ( - - {({ values, handleChange, handleBlur }) => ( - - )} - +
+ + + {formatMessage(messages.heading)} + + + + {renderBody()} + + + + + {formatMessage(messages.cancelLabel)} + + + + +
+ ); }; ORASettings.propTypes = { - intl: intlShape.isRequired, onClose: PropTypes.func.isRequired, }; -export default injectIntl(ORASettings); +export default ORASettings; diff --git a/plugins/course-apps/ora_settings/Settings.test.jsx b/plugins/course-apps/ora_settings/Settings.test.jsx index d74cab9e69..a037e0c438 100644 --- a/plugins/course-apps/ora_settings/Settings.test.jsx +++ b/plugins/course-apps/ora_settings/Settings.test.jsx @@ -1,33 +1,152 @@ -import { shallow } from '@edx/react-unit-test-utils'; +import { + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import ReactDOM from 'react-dom'; +import { Routes, Route, MemoryRouter } from 'react-router-dom'; +import { initializeMockApp } from '@edx/frontend-platform'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { AppProvider, PageWrap } from '@edx/frontend-platform/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import initializeStore from 'CourseAuthoring/store'; +import { executeThunk } from 'CourseAuthoring/utils'; +import PagesAndResourcesProvider from 'CourseAuthoring/pages-and-resources/PagesAndResourcesProvider'; +import { getCourseAppsApiUrl, getCourseAdvancedSettingsApiUrl } from 'CourseAuthoring/pages-and-resources/data/api'; +import { fetchCourseApps, fetchCourseAppSettings } from 'CourseAuthoring/pages-and-resources/data/thunks'; import ORASettings from './Settings'; +import messages from './messages'; +import { + courseId, + inititalState, +} from './factories/mockData'; + +let axiosMock; +let store; +const oraSettingsUrl = `/course/${courseId}/pages-and-resources/live/settings`; + +// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest. +ReactDOM.createPortal = jest.fn(node => node); + +const renderComponent = () => ( + render( + + + + + + } /> + + + + + , + ) +); -jest.mock('@edx/frontend-platform/i18n', () => ({ - ...jest.requireActual('@edx/frontend-platform/i18n'), // use actual for all non-hook parts - injectIntl: (component) => component, - intlShape: {}, -})); -jest.mock('yup', () => ({ - boolean: jest.fn().mockReturnValue('Yub.boolean'), -})); -jest.mock('CourseAuthoring/generic/model-store', () => ({ - useModel: jest.fn().mockReturnValue({ documentationLinks: { learnMoreConfiguration: 'https://learnmore.test' } }), -})); -jest.mock('CourseAuthoring/generic/FormSwitchGroup', () => 'FormSwitchGroup'); -jest.mock('CourseAuthoring/utils', () => ({ - useAppSetting: jest.fn().mockReturnValue(['abitrary value', jest.fn().mockName('saveSetting')]), -})); -jest.mock('CourseAuthoring/pages-and-resources/app-settings-modal/AppSettingsModal', () => 'AppSettingsModal'); - -const props = { - onClose: jest.fn().mockName('onClose'), - intl: { - formatMessage: (message) => message.defaultMessage, - }, +const mockStore = async ({ + apiStatus, + enabled, +}) => { + const settings = ['forceOnFlexiblePeerOpenassessments']; + const fetchCourseAppsUrl = `${getCourseAppsApiUrl()}/${courseId}`; + const fetchAdvancedSettingsUrl = `${getCourseAdvancedSettingsApiUrl()}/${courseId}`; + + axiosMock.onGet(fetchCourseAppsUrl).reply( + 200, + [{ + allowed_operations: { enable: false, configure: true }, + description: 'setting', + documentation_links: { learnMoreConfiguration: '' }, + enabled, + id: 'ora_settings', + name: 'Flexible Peer Grading for ORAs', + }], + ); + axiosMock.onGet(fetchAdvancedSettingsUrl).reply( + apiStatus, + { force_on_flexible_peer_openassessments: { value: enabled } }, + ); + + await executeThunk(fetchCourseApps(courseId), store.dispatch); + await executeThunk(fetchCourseAppSettings(courseId, settings), store.dispatch); }; describe('ORASettings', () => { - it('should render', () => { - const wrapper = shallow(); - expect(wrapper.snapshot).toMatchSnapshot(); + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: false, + roles: [], + }, + }); + store = initializeStore(inititalState); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + it('Flexible peer grading configuration modal is visible', async () => { + renderComponent(); + expect(screen.getByRole('dialog')).toBeVisible(); + }); + + it('Displays "Configure Flexible Peer Grading" heading', async () => { + renderComponent(); + const headingElement = screen.getByText(messages.heading.defaultMessage); + + expect(headingElement).toBeVisible(); + }); + + it('Displays loading component', () => { + renderComponent(); + const loadingElement = screen.getByRole('status'); + + expect(within(loadingElement).getByText('Loading...')).toBeInTheDocument(); + }); + + it('Displays Connection Error Alert', async () => { + await mockStore({ apiStatus: 404, enabled: true }); + renderComponent(); + const errorAlert = screen.getByRole('alert'); + + expect(within(errorAlert).getByText('We encountered a technical error when loading this page.', { exact: false })).toBeVisible(); + }); + + it('Displays Permissions Error Alert', async () => { + await mockStore({ apiStatus: 403, enabled: true }); + renderComponent(); + const errorAlert = screen.getByRole('alert'); + + expect(within(errorAlert).getByText('You are not authorized to view this page', { exact: false })).toBeVisible(); + }); + + it('Displays title, helper text and badge when flexible peer grading button is enabled', async () => { + renderComponent(); + await mockStore({ apiStatus: 200, enabled: true }); + + waitFor(() => { + const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage); + const enableBadge = screen.getByTestId('enable-badge'); + + expect(label).toBeVisible(); + + expect(enableBadge).toHaveTextContent('Enabled'); + }); + }); + + it('Displays title, helper text and hides badge when flexible peer grading button is disabled', async () => { + renderComponent(); + await mockStore({ apiStatus: 200, enabled: false }); + + const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage); + const enableBadge = screen.queryByTestId('enable-badge'); + + expect(label).toBeVisible(); + + expect(enableBadge).toBeNull(); }); }); diff --git a/plugins/course-apps/ora_settings/__snapshots__/Settings.test.jsx.snap b/plugins/course-apps/ora_settings/__snapshots__/Settings.test.jsx.snap deleted file mode 100644 index 676fae11a9..0000000000 --- a/plugins/course-apps/ora_settings/__snapshots__/Settings.test.jsx.snap +++ /dev/null @@ -1,47 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ORASettings should render 1`] = ` - -

- Configure open response assessment -

-
- - Learn more about open response assessment settings - -
- - } - validationSchema={ - { - "enableFlexiblePeerGrade": "Yub.boolean", - } - } -> - [Function] -
-`; diff --git a/plugins/course-apps/ora_settings/factories/mockData.js b/plugins/course-apps/ora_settings/factories/mockData.js new file mode 100644 index 0000000000..a86ccc6a13 --- /dev/null +++ b/plugins/course-apps/ora_settings/factories/mockData.js @@ -0,0 +1,32 @@ +export const courseId = 'course-v1:org+num+run'; + +export const inititalState = { + courseDetail: { + courseId, + status: 'successful', + }, + pagesAndResources: { + courseAppIds: ['ora_settings'], + loadingStatus: 'in-progress', + savingStatus: '', + courseAppsApiStatus: {}, + courseAppSettings: {}, + }, + models: { + courseApps: { + ora_settings: { + id: 'ora_settings', + name: 'Flexible Peer Grading', + enabled: true, + description: 'Enable flexible peer grading', + allowedOperations: { + enable: false, + configure: true, + }, + documentationLinks: { + learnMoreConfiguration: '', + }, + }, + }, + }, +}; diff --git a/plugins/course-apps/ora_settings/messages.js b/plugins/course-apps/ora_settings/messages.js index 7b05afa5d4..3b119b5660 100644 --- a/plugins/course-apps/ora_settings/messages.js +++ b/plugins/course-apps/ora_settings/messages.js @@ -3,19 +3,51 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ heading: { id: 'course-authoring.pages-resources.ora.heading', - defaultMessage: 'Configure open response assessment', + defaultMessage: 'Configure Flexible Peer Grading', + description: 'Title for the modal dialog header', }, ORASettingsHelpLink: { id: 'course-authoring.pages-resources.ora.flex-peer-grading.link', defaultMessage: 'Learn more about open response assessment settings', + description: 'Descriptive text for the hyperlink to the docs site', }, enableFlexPeerGradeLabel: { id: 'course-authoring.pages-resources.ora.flex-peer-grading.label', defaultMessage: 'Flex Peer Grading', + description: 'Label for form switch', }, enableFlexPeerGradeHelp: { id: 'course-authoring.pages-resources.ora.flex-peer-grading.help', defaultMessage: 'Turn on Flexible Peer Grading for all open response assessments in the course with peer grading.', + description: 'Help text describing what happens when the switch is enabled', + }, + enabledBadgeLabel: { + id: 'course-authoring.pages-resources.ora.flex-peer-grading.enabled-badge.label', + defaultMessage: 'Enabled', + description: 'Label for badge that show users that a setting is enabled', + }, + cancelLabel: { + id: 'course-authoring.pages-resources.ora.flex-peer-grading.cancel-button.label', + defaultMessage: 'Cancel', + description: 'Label for button that cancels user changes', + }, + saveLabel: { + id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-button.label', + defaultMessage: 'Save', + description: 'Label for button that saves user changes', + }, + pendingSaveLabel: { + id: 'course-authoring.pages-resources.ora.flex-peer-grading.pending-save-button.label', + defaultMessage: 'Saving', + description: 'Label for button that has pending api save calls', + }, + errorSavingTitle: { + id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-error.title', + defaultMessage: 'We couldn\'t apply your changes.', + }, + errorSavingMessage: { + id: 'course-authoring.pages-resources.ora.flex-peer-grading.save-error.message', + defaultMessage: 'Please check your entries and try again.', }, }); diff --git a/plugins/course-apps/ora_settings/package.json b/plugins/course-apps/ora_settings/package.json index d6de338820..8cc4bf2243 100644 --- a/plugins/course-apps/ora_settings/package.json +++ b/plugins/course-apps/ora_settings/package.json @@ -8,6 +8,7 @@ "@openedx/paragon": "*", "prop-types": "*", "react": "*", + "react-redux": "*", "yup": "*" }, "peerDependenciesMeta": { diff --git a/src/content-tags-drawer/ContentTagsDrawer.jsx b/src/content-tags-drawer/ContentTagsDrawer.jsx deleted file mode 100644 index 117ffc3947..0000000000 --- a/src/content-tags-drawer/ContentTagsDrawer.jsx +++ /dev/null @@ -1,263 +0,0 @@ -// @ts-check -import React, { useContext, useEffect } from 'react'; -import PropTypes from 'prop-types'; -import { - Container, - Spinner, - Stack, - Button, - Toast, -} from '@openedx/paragon'; -import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; -import { useParams, useNavigate } from 'react-router-dom'; -import messages from './messages'; -import ContentTagsCollapsible from './ContentTagsCollapsible'; -import Loading from '../generic/Loading'; -import useContentTagsDrawerContext from './ContentTagsDrawerHelper'; -import { ContentTagsDrawerContext, ContentTagsDrawerSheetContext } from './common/context'; - -const TaxonomyList = ({ contentId }) => { - const navigate = useNavigate(); - const intl = useIntl(); - - const { - isTaxonomyListLoaded, - isContentTaxonomyTagsLoaded, - tagsByTaxonomy, - stagedContentTags, - collapsibleStates, - } = React.useContext(ContentTagsDrawerContext); - - if (isTaxonomyListLoaded && isContentTaxonomyTagsLoaded) { - if (tagsByTaxonomy.length !== 0) { - return ( -
- { tagsByTaxonomy.map((data) => ( -
- -
-
- ))} -
- ); - } - - return ( - navigate('/taxonomies')} - > - { intl.formatMessage(messages.emptyDrawerContentLink) } - - ), - }} - /> - ); - } - - return ; -}; - -TaxonomyList.propTypes = { - contentId: PropTypes.string.isRequired, -}; - -/** - * Drawer with the functionality to show and manage tags in a certain content. - * It is used both in interfaces of this MFE and in edx-platform interfaces such as iframe. - * - If you want to use it as an iframe, the component obtains the `contentId` from the url parameters. - * Functions to close the drawer are handled internally. - * TODO: We can delete this method when is no longer used on edx-platform. - * - If you want to use it as react component, you need to pass the content id and the close functions - * through the component parameters. - */ -const ContentTagsDrawer = ({ id, onClose }) => { - const intl = useIntl(); - // TODO: We can delete 'params' when the iframe is no longer used on edx-platform - const params = useParams(); - const contentId = id ?? params.contentId; - - const context = useContentTagsDrawerContext(contentId); - const { blockingSheet } = useContext(ContentTagsDrawerSheetContext); - - const { - showToastAfterSave, - toReadMode, - commitGlobalStagedTagsStatus, - isContentDataLoaded, - contentName, - isTaxonomyListLoaded, - isContentTaxonomyTagsLoaded, - stagedContentTags, - collapsibleStates, - isEditMode, - commitGlobalStagedTags, - toEditMode, - toastMessage, - closeToast, - setCollapsibleToInitalState, - otherTaxonomies, - } = context; - - let onCloseDrawer = onClose; - if (onCloseDrawer === undefined) { - onCloseDrawer = () => { - // "*" allows communication with any origin - window.parent.postMessage('closeManageTagsDrawer', '*'); - }; - } - - useEffect(() => { - const handleEsc = (event) => { - /* Close drawer when ESC-key is pressed and selectable dropdown box not open */ - const selectableBoxOpen = document.querySelector('[data-selectable-box="taxonomy-tags"]'); - if (event.key === 'Escape' && !selectableBoxOpen && !blockingSheet) { - onCloseDrawer(); - } - }; - document.addEventListener('keydown', handleEsc); - - return () => { - document.removeEventListener('keydown', handleEsc); - }; - }, [blockingSheet]); - - useEffect(() => { - /* istanbul ignore next */ - if (commitGlobalStagedTagsStatus === 'success') { - showToastAfterSave(); - toReadMode(); - } - }, [commitGlobalStagedTagsStatus]); - - // First call of the initial collapsible states - React.useEffect(() => { - setCollapsibleToInitalState(); - }, [isTaxonomyListLoaded, isContentTaxonomyTagsLoaded]); - - return ( - -
- - { isContentDataLoaded - ?

{ contentName }

- : ( -
- -
- )} -
- -

- {intl.formatMessage(messages.headerSubtitle)} -

- - {otherTaxonomies.length !== 0 && ( -
-

- {intl.formatMessage(messages.otherTagsHeader)} -

-

- {intl.formatMessage(messages.otherTagsDescription)} -

- { isTaxonomyListLoaded && isContentTaxonomyTagsLoaded && ( - otherTaxonomies.map((data) => ( -
- -
-
- )) - )} -
- )} -
-
- - { isTaxonomyListLoaded && isContentTaxonomyTagsLoaded && ( - -
- { commitGlobalStagedTagsStatus !== 'loading' ? ( - - - - - ) - : ( - - )} -
-
- )} - {/* istanbul ignore next */ - toastMessage && ( - - {toastMessage} - - ) - } -
-
- ); -}; - -ContentTagsDrawer.propTypes = { - id: PropTypes.string, - onClose: PropTypes.func, -}; - -ContentTagsDrawer.defaultProps = { - id: undefined, - onClose: undefined, -}; - -export default ContentTagsDrawer; diff --git a/src/content-tags-drawer/ContentTagsDrawer.test.jsx b/src/content-tags-drawer/ContentTagsDrawer.test.jsx index 8f2e517c35..8abd78e1fb 100644 --- a/src/content-tags-drawer/ContentTagsDrawer.test.jsx +++ b/src/content-tags-drawer/ContentTagsDrawer.test.jsx @@ -1,589 +1,141 @@ -import React from 'react'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, fireEvent, + initializeMocks, render, waitFor, screen, within, -} from '@testing-library/react'; - +} from '../testUtils'; import ContentTagsDrawer from './ContentTagsDrawer'; -import { - useContentTaxonomyTagsData, - useContentData, - useTaxonomyTagsData, - useContentTaxonomyTagsUpdater, -} from './data/apiHooks'; -import { getTaxonomyListData } from '../taxonomy/data/api'; import messages from './messages'; import { ContentTagsDrawerSheetContext } from './common/context'; -import { languageExportId } from './utils'; - -const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab'; +import { + mockContentData, + mockContentTaxonomyTagsData, + mockTaxonomyListData, + mockTaxonomyTagsData, +} from './data/api.mocks'; +import { getContentTaxonomyTagsApiUrl } from './data/api'; + +const path = '/content/:contentId/*'; const mockOnClose = jest.fn(); -const mockMutate = jest.fn(); const mockSetBlockingSheet = jest.fn(); const mockNavigate = jest.fn(); +mockContentTaxonomyTagsData.applyMock(); +mockTaxonomyListData.applyMock(); +mockTaxonomyTagsData.applyMock(); +mockContentData.applyMock(); + +const { + stagedTagsId, + otherTagsId, + languageWithTagsId, + languageWithoutTagsId, + largeTagsId, + emptyTagsId, +} = mockContentTaxonomyTagsData; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), - useParams: () => ({ - contentId, - }), useNavigate: () => mockNavigate, })); -// FIXME: replace these mocks with API mocks -jest.mock('./data/apiHooks', () => ({ - useContentTaxonomyTagsData: jest.fn(() => {}), - useContentData: jest.fn(() => ({ - isSuccess: false, - data: {}, - })), - useContentTaxonomyTagsUpdater: jest.fn(() => ({ - isError: false, - mutate: mockMutate, - })), - useTaxonomyTagsData: jest.fn(() => ({ - hasMorePages: false, - tagPages: { - isLoading: true, - isError: false, - canAddTag: false, - data: [], - }, - })), -})); - -jest.mock('../taxonomy/data/api', () => ({ - // By default, the mock taxonomy list will never load (promise never resolves): - getTaxonomyListData: jest.fn(), -})); - -const queryClient = new QueryClient(); - -const RootWrapper = (params) => ( - - - - - - - +const renderDrawer = (contentId, drawerParams = {}) => ( + render( + + + , + { path, params: { contentId } }, + ) ); describe('', () => { beforeEach(async () => { - jest.clearAllMocks(); - await queryClient.resetQueries(); - // By default, we mock the API call with a promise that never resolves. - // You can override this in specific test. - getTaxonomyListData.mockReturnValue(new Promise(() => {})); - useContentTaxonomyTagsUpdater.mockReturnValue({ - isError: false, - mutate: mockMutate, - }); + initializeMocks(); }); - const setupMockDataForStagedTagsTesting = () => { - useContentTaxonomyTagsData.mockReturnValue({ - isSuccess: true, - data: { - taxonomies: [ - { - name: 'Taxonomy 1', - taxonomyId: 123, - canTagObject: true, - tags: [ - { - value: 'Tag 1', - lineage: ['Tag 1'], - canDeleteObjecttag: true, - }, - { - value: 'Tag 2', - lineage: ['Tag 2'], - canDeleteObjecttag: true, - }, - ], - }, - ], - }, - }); - getTaxonomyListData.mockResolvedValue({ - results: [ - { - id: 123, - name: 'Taxonomy 1', - description: 'This is a description 1', - canTagObject: true, - }, - ], - }); - - useTaxonomyTagsData.mockReturnValue({ - hasMorePages: false, - canAddTag: false, - tagPages: { - isLoading: false, - isError: false, - data: [{ - value: 'Tag 1', - externalId: null, - childCount: 0, - depth: 0, - parentValue: null, - id: 12345, - subTagsUrl: null, - canChangeTag: false, - canDeleteTag: false, - }, { - value: 'Tag 2', - externalId: null, - childCount: 0, - depth: 0, - parentValue: null, - id: 12346, - subTagsUrl: null, - canChangeTag: false, - canDeleteTag: false, - }, { - value: 'Tag 3', - externalId: null, - childCount: 0, - depth: 0, - parentValue: null, - id: 12347, - subTagsUrl: null, - canChangeTag: false, - canDeleteTag: false, - }], - }, - }); - }; - - const setupMockDataWithOtherTagsTestings = () => { - useContentTaxonomyTagsData.mockReturnValue({ - isSuccess: true, - data: { - taxonomies: [ - { - name: 'Taxonomy 1', - taxonomyId: 123, - canTagObject: true, - tags: [ - { - value: 'Tag 1', - lineage: ['Tag 1'], - canDeleteObjecttag: true, - }, - { - value: 'Tag 2', - lineage: ['Tag 2'], - canDeleteObjecttag: true, - }, - ], - }, - { - name: 'Taxonomy 2', - taxonomyId: 1234, - canTagObject: false, - tags: [ - { - value: 'Tag 3', - lineage: ['Tag 3'], - canDeleteObjecttag: true, - }, - { - value: 'Tag 4', - lineage: ['Tag 4'], - canDeleteObjecttag: true, - }, - ], - }, - ], - }, - }); - getTaxonomyListData.mockResolvedValue({ - results: [ - { - id: 123, - name: 'Taxonomy 1', - description: 'This is a description 1', - canTagObject: true, - }, - ], - }); - - useTaxonomyTagsData.mockReturnValue({ - hasMorePages: false, - canAddTag: false, - tagPages: { - isLoading: false, - isError: false, - data: [{ - value: 'Tag 1', - externalId: null, - childCount: 0, - depth: 0, - parentValue: null, - id: 12345, - subTagsUrl: null, - canChangeTag: false, - canDeleteTag: false, - }, { - value: 'Tag 2', - externalId: null, - childCount: 0, - depth: 0, - parentValue: null, - id: 12346, - subTagsUrl: null, - canChangeTag: false, - canDeleteTag: false, - }, { - value: 'Tag 3', - externalId: null, - childCount: 0, - depth: 0, - parentValue: null, - id: 12347, - subTagsUrl: null, - canChangeTag: false, - canDeleteTag: false, - }], - }, - }); - }; - - const setupMockDataLanguageTaxonomyTestings = (hasTags) => { - useContentTaxonomyTagsData.mockReturnValue({ - isSuccess: true, - data: { - taxonomies: [ - { - name: 'Languages', - taxonomyId: 123, - exportId: languageExportId, - canTagObject: true, - tags: hasTags ? [ - { - value: 'Tag 1', - lineage: ['Tag 1'], - canDeleteObjecttag: true, - }, - ] : [], - }, - { - name: 'Taxonomy 1', - taxonomyId: 1234, - canTagObject: true, - tags: [ - { - value: 'Tag 1', - lineage: ['Tag 1'], - canDeleteObjecttag: true, - }, - { - value: 'Tag 2', - lineage: ['Tag 2'], - canDeleteObjecttag: true, - }, - ], - }, - ], - }, - }); - getTaxonomyListData.mockResolvedValue({ - results: [ - { - id: 123, - name: 'Languages', - description: 'This is a description 1', - exportId: languageExportId, - canTagObject: true, - }, - { - id: 1234, - name: 'Taxonomy 1', - description: 'This is a description 2', - canTagObject: true, - }, - ], - }); - - useTaxonomyTagsData.mockReturnValue({ - hasMorePages: false, - canAddTag: false, - tagPages: { - isLoading: false, - isError: false, - data: [{ - value: 'Tag 1', - externalId: null, - childCount: 0, - depth: 0, - parentValue: null, - id: 12345, - subTagsUrl: null, - canChangeTag: false, - canDeleteTag: false, - }], - }, - }); - }; - - const setupLargeMockDataForStagedTagsTesting = () => { - useContentTaxonomyTagsData.mockReturnValue({ - isSuccess: true, - data: { - taxonomies: [ - { - name: 'Taxonomy 1', - taxonomyId: 123, - canTagObject: true, - tags: [ - { - value: 'Tag 1', - lineage: ['Tag 1'], - canDeleteObjecttag: true, - }, - { - value: 'Tag 2', - lineage: ['Tag 2'], - canDeleteObjecttag: true, - }, - ], - }, - { - 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, - }, - { - 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({ - hasMorePages: false, - canAddTag: false, - tagPages: { - isLoading: false, - isError: false, - data: [{ - value: 'Tag 1', - externalId: null, - childCount: 0, - depth: 0, - parentValue: null, - id: 12345, - subTagsUrl: null, - canChangeTag: false, - canDeleteTag: false, - }, { - value: 'Tag 2', - externalId: null, - childCount: 0, - depth: 0, - parentValue: null, - id: 12346, - subTagsUrl: null, - canChangeTag: false, - canDeleteTag: false, - }, { - value: 'Tag 3', - externalId: null, - childCount: 0, - depth: 0, - parentValue: null, - id: 12347, - subTagsUrl: null, - canChangeTag: false, - canDeleteTag: false, - }], - }, - }); - }; + afterEach(() => { + jest.clearAllMocks(); + }); it('should render page and page title correctly', () => { - setupMockDataForStagedTagsTesting(); - const { getByText } = render(); - expect(getByText('Manage tags')).toBeInTheDocument(); + renderDrawer(stagedTagsId); + expect(screen.getByText('Manage tags')).toBeInTheDocument(); }); it('shows spinner before the content data query is complete', async () => { await act(async () => { - const { getAllByRole } = render(); - const spinner = getAllByRole('status')[0]; + renderDrawer(stagedTagsId); + const spinner = screen.getAllByRole('status')[0]; expect(spinner.textContent).toEqual('Loading'); // Uses }); }); it('shows spinner before the taxonomy tags query is complete', async () => { await act(async () => { - const { getAllByRole } = render(); - const spinner = getAllByRole('status')[1]; + renderDrawer(stagedTagsId); + const spinner = screen.getAllByRole('status')[1]; expect(spinner.textContent).toEqual('Loading...'); // Uses }); }); - it('shows the content display name after the query is complete', async () => { - useContentData.mockReturnValue({ - isSuccess: true, - data: { - displayName: 'Unit 1', - }, - }); - await act(async () => { - const { getByText } = render(); - expect(getByText('Unit 1')).toBeInTheDocument(); - }); + it('shows the content display name after the query is complete in drawer variant', async () => { + renderDrawer('test'); + expect(await screen.findByText('Loading...')).toBeInTheDocument(); + expect(await screen.findByText('Unit 1')).toBeInTheDocument(); + expect(await screen.findByText('Manage tags')).toBeInTheDocument(); + }); + + it('shows the content display name after the query is complete in component variant', async () => { + renderDrawer('test', { variant: 'component' }); + expect(await screen.findByText('Loading...')).toBeInTheDocument(); + expect(screen.queryByText('Unit 1')).not.toBeInTheDocument(); + expect(screen.queryByText('Manage tags')).not.toBeInTheDocument(); }); it('shows content using params', async () => { - useContentData.mockReturnValue({ - isSuccess: true, - data: { - displayName: 'Unit 1', - }, - }); - render(); - expect(screen.getByText('Unit 1')).toBeInTheDocument(); + renderDrawer(undefined, { id: 'test' }); + expect(await screen.findByText('Loading...')).toBeInTheDocument(); + expect(await screen.findByText('Unit 1')).toBeInTheDocument(); + expect(await screen.findByText('Manage tags')).toBeInTheDocument(); }); it('shows the taxonomies data including tag numbers after the query is complete', async () => { - useContentTaxonomyTagsData.mockReturnValue({ - isSuccess: true, - data: { - taxonomies: [ - { - name: 'Taxonomy 1', - taxonomyId: 123, - canTagObject: true, - tags: [ - { - value: 'Tag 1', - lineage: ['Tag 1'], - canDeleteObjecttag: true, - }, - { - value: 'Tag 2', - lineage: ['Tag 2'], - canDeleteObjecttag: true, - }, - ], - }, - { - name: 'Taxonomy 2', - taxonomyId: 124, - canTagObject: true, - tags: [ - { - value: 'Tag 3', - lineage: ['Tag 3'], - canDeleteObjecttag: true, - }, - ], - }, - ], - }, - }); - getTaxonomyListData.mockResolvedValue({ - results: [{ - id: 123, - name: 'Taxonomy 1', - description: 'This is a description 1', - canTagObject: false, - }, { - id: 124, - name: 'Taxonomy 2', - description: 'This is a description 2', - canTagObject: false, - }], - }); await act(async () => { - const { container, getByText } = render(); - await waitFor(() => { expect(getByText('Taxonomy 1')).toBeInTheDocument(); }); - expect(getByText('Taxonomy 1')).toBeInTheDocument(); - expect(getByText('Taxonomy 2')).toBeInTheDocument(); + const { container } = renderDrawer(largeTagsId); + await waitFor(() => { expect(screen.getByText('Taxonomy 1')).toBeInTheDocument(); }); + expect(screen.getByText('Taxonomy 1')).toBeInTheDocument(); + expect(screen.getByText('Taxonomy 2')).toBeInTheDocument(); const tagCountBadges = container.getElementsByClassName('taxonomy-tags-count-chip'); - expect(tagCountBadges[0].textContent).toBe('2'); - expect(tagCountBadges[1].textContent).toBe('1'); + expect(tagCountBadges[0].textContent).toBe('3'); + expect(tagCountBadges[1].textContent).toBe('2'); }); }); - it('should be read only on first render', async () => { - setupMockDataForStagedTagsTesting(); - render(); + it('should be read only on first render on drawer variant', async () => { + renderDrawer(stagedTagsId); + expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /close/i })); + expect(screen.getByRole('button', { name: /edit tags/i })); + + // Not show delete tag buttons + expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument(); + + // Not show add a tag select + expect(screen.queryByText(/add a tag/i)).not.toBeInTheDocument(); + + // Not show cancel button + expect(screen.queryByRole('button', { name: /cancel/i })).not.toBeInTheDocument(); + + // Not show save button + expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument(); + }); + + it('should be read only on first render on component variant', async () => { + renderDrawer(stagedTagsId, { variant: 'component' }); expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /manage tags/i })); // Not show delete tag buttons expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument(); @@ -598,9 +150,8 @@ describe('', () => { expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument(); }); - it('should change to edit mode when click on `Edit tags`', async () => { - setupMockDataForStagedTagsTesting(); - render(); + it('should change to edit mode when click on `Edit tags` on drawer variant', async () => { + renderDrawer(stagedTagsId); expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument(); const editTagsButton = screen.getByRole('button', { name: /edit tags/i, @@ -622,9 +173,31 @@ describe('', () => { expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); }); - it('should change to read mode when click on `Cancel`', async () => { - setupMockDataForStagedTagsTesting(); - render(); + it('should change to edit mode when click on `Manage tags` on component variant', async () => { + renderDrawer(stagedTagsId, { variant: 'component' }); + expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument(); + const manageTagsButton = screen.getByRole('button', { + name: /manage tags/i, + }); + fireEvent.click(manageTagsButton); + + // Show delete tag buttons + expect(screen.getAllByRole('button', { + name: /delete/i, + }).length).toBe(2); + + // Show add a tag select + expect(screen.getByText(/add a tag/i)).toBeInTheDocument(); + + // Show cancel button + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + + // Show save button + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); + }); + + it('should change to read mode when click on `Cancel` on drawer variant', async () => { + renderDrawer(stagedTagsId); expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument(); const editTagsButton = screen.getByRole('button', { name: /edit tags/i, @@ -649,21 +222,34 @@ describe('', () => { expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument(); }); - it('shows spinner when loading commit tags', async () => { - setupMockDataForStagedTagsTesting(); - useContentTaxonomyTagsUpdater.mockReturnValue({ - status: 'loading', - isError: false, - mutate: mockMutate, - }); - render(); + it('should change to read mode when click on `Cancel` on component variant', async () => { + renderDrawer(stagedTagsId, { variant: 'component' }); expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument(); - expect(screen.getByRole('status')).toBeInTheDocument(); + const manageTagsButton = screen.getByRole('button', { + name: /manage tags/i, + }); + fireEvent.click(manageTagsButton); + + const cancelButton = screen.getByRole('button', { + name: /cancel/i, + }); + fireEvent.click(cancelButton); + + // Not show delete tag buttons + expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument(); + + // Not show add a tag select + expect(screen.queryByText(/add a tag/i)).not.toBeInTheDocument(); + + // Not show cancel button + expect(screen.queryByRole('button', { name: /cancel/i })).not.toBeInTheDocument(); + + // Not show save button + expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument(); }); it('should test adding a content tag to the staged tags for a taxonomy', async () => { - setupMockDataForStagedTagsTesting(); - render(); + renderDrawer(stagedTagsId); expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument(); // To edit mode @@ -678,7 +264,7 @@ describe('', () => { fireEvent.mouseDown(addTagsButton); // Tag 3 should only appear in dropdown selector, (i.e. the dropdown is open, since Tag 3 is not applied) - expect(screen.getAllByText('Tag 3').length).toBe(1); + expect((await screen.findAllByText('Tag 3')).length).toBe(1); // Click to check Tag 3 const tag3 = screen.getByText('Tag 3'); @@ -689,8 +275,7 @@ describe('', () => { }); it('should test removing a staged content from a taxonomy', async () => { - setupMockDataForStagedTagsTesting(); - render(); + renderDrawer(stagedTagsId); expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument(); // To edit mode @@ -705,7 +290,7 @@ describe('', () => { fireEvent.mouseDown(addTagsButton); // Tag 3 should only appear in dropdown selector, (i.e. the dropdown is open, since Tag 3 is not applied) - expect(screen.getAllByText('Tag 3').length).toBe(1); + expect((await screen.findAllByText('Tag 3')).length).toBe(1); // Click to check Tag 3 const tag3 = screen.getByText('Tag 3'); @@ -720,11 +305,9 @@ describe('', () => { }); it('should test clearing staged tags for a taxonomy', async () => { - setupMockDataForStagedTagsTesting(); - const { container, - } = render(); + } = renderDrawer(stagedTagsId); expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument(); // To edit mode @@ -739,7 +322,7 @@ describe('', () => { fireEvent.mouseDown(addTagsButton); // Tag 3 should only appear in dropdown selector, (i.e. the dropdown is open, since Tag 3 is not applied) - expect(screen.getAllByText('Tag 3').length).toBe(1); + expect((await screen.findAllByText('Tag 3')).length).toBe(1); // Click to check Tag 3 const tag3 = screen.getByText('Tag 3'); @@ -758,8 +341,7 @@ describe('', () => { }); it('should test adding global staged tags and cancel', async () => { - setupMockDataForStagedTagsTesting(); - render(); + renderDrawer(stagedTagsId); expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument(); // To edit mode @@ -774,7 +356,7 @@ describe('', () => { fireEvent.mouseDown(addTagsButton); // Click to check Tag 3 - const tag3 = screen.getByText(/tag 3/i); + const tag3 = await screen.findByText(/tag 3/i); fireEvent.click(tag3); // Click "Add tags" to save to global staged tags @@ -791,8 +373,7 @@ describe('', () => { }); it('should test delete feched tags and cancel', async () => { - setupMockDataForStagedTagsTesting(); - render(); + renderDrawer(stagedTagsId); expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument(); // To edit mode @@ -802,7 +383,7 @@ describe('', () => { fireEvent.click(editTagsButton); // Delete the tag - const tag = screen.getByText(/tag 2/i); + const tag = await screen.findByText(/tag 2/i); const deleteButton = within(tag).getByRole('button', { name: /delete/i, }); @@ -818,8 +399,7 @@ describe('', () => { }); it('should test delete global staged tags and cancel', async () => { - setupMockDataForStagedTagsTesting(); - render(); + renderDrawer(stagedTagsId); expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument(); // To edit mode @@ -834,7 +414,7 @@ describe('', () => { fireEvent.mouseDown(addTagsButton); // Click to check Tag 3 - const tag3 = screen.getByText(/tag 3/i); + const tag3 = await screen.findByText(/tag 3/i); fireEvent.click(tag3); // Click "Add tags" to save to global staged tags @@ -860,8 +440,7 @@ describe('', () => { }); it('should test add removed feched tags and cancel', async () => { - setupMockDataForStagedTagsTesting(); - render(); + renderDrawer(stagedTagsId); expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument(); // To edit mode @@ -871,7 +450,7 @@ describe('', () => { fireEvent.click(editTagsButton); // Delete the tag - const tag = screen.getByText(/tag 2/i); + const tag = await screen.findByText(/tag 2/i); const deleteButton = within(tag).getByRole('button', { name: /delete/i, }); @@ -885,7 +464,7 @@ describe('', () => { fireEvent.mouseDown(addTagsButton); // Click to check Tag 2 - const tag2 = screen.getByText(/tag 2/i); + const tag2 = await screen.findByText(/tag 2/i); fireEvent.click(tag2); // Click "Add tags" to save to global staged tags @@ -902,8 +481,7 @@ describe('', () => { }); it('should call onClose when cancel is clicked', async () => { - setupMockDataForStagedTagsTesting(); - render(); + renderDrawer(stagedTagsId, { onClose: mockOnClose }); const cancelButton = await screen.findByRole('button', { name: /close/i, @@ -917,7 +495,7 @@ describe('', () => { it('should call closeManageTagsDrawer when Escape key is pressed and no selectable box is active', () => { const postMessageSpy = jest.spyOn(window.parent, 'postMessage'); - const { container } = render(); + const { container } = renderDrawer(stagedTagsId); fireEvent.keyDown(container, { key: 'Escape', @@ -929,7 +507,7 @@ describe('', () => { }); it('should call `onClose` when Escape key is pressed and no selectable box is active', () => { - const { container } = render(); + const { container } = renderDrawer(stagedTagsId, { onClose: mockOnClose }); fireEvent.keyDown(container, { key: 'Escape', @@ -941,7 +519,7 @@ describe('', () => { it('should not call closeManageTagsDrawer when Escape key is pressed and a selectable box is active', () => { const postMessageSpy = jest.spyOn(window.parent, 'postMessage'); - const { container } = render(); + const { container } = renderDrawer(stagedTagsId); // Simulate that the selectable box is open by adding an element with the data attribute const selectableBox = document.createElement('div'); @@ -961,7 +539,7 @@ describe('', () => { }); it('should not call `onClose` when Escape key is pressed and a selectable box is active', () => { - const { container } = render(); + const { container } = renderDrawer(stagedTagsId, { onClose: mockOnClose }); // Simulate that the selectable box is open by adding an element with the data attribute const selectableBox = document.createElement('div'); @@ -980,8 +558,7 @@ describe('', () => { it('should not call closeManageTagsDrawer when Escape key is pressed and container is blocked', () => { const postMessageSpy = jest.spyOn(window.parent, 'postMessage'); - - const { container } = render(); + const { container } = renderDrawer(stagedTagsId, { blockingSheet: true }); fireEvent.keyDown(container, { key: 'Escape', }); @@ -992,7 +569,10 @@ describe('', () => { }); it('should not call `onClose` when Escape key is pressed and container is blocked', () => { - const { container } = render(); + const { container } = renderDrawer(stagedTagsId, { + blockingSheet: true, + onClose: mockOnClose, + }); fireEvent.keyDown(container, { key: 'Escape', }); @@ -1001,8 +581,10 @@ describe('', () => { }); it('should call `setBlockingSheet` on add a tag', async () => { - setupMockDataForStagedTagsTesting(); - render(); + renderDrawer(stagedTagsId, { + blockingSheet: true, + setBlockingSheet: mockSetBlockingSheet, + }); expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument(); expect(mockSetBlockingSheet).toHaveBeenCalledWith(false); @@ -1019,7 +601,7 @@ describe('', () => { fireEvent.mouseDown(addTagsButton); // Click to check Tag 3 - const tag3 = screen.getByText(/tag 3/i); + const tag3 = await screen.findByText(/tag 3/i); fireEvent.click(tag3); // Click "Add tags" to save to global staged tags @@ -1030,8 +612,10 @@ describe('', () => { }); it('should call `setBlockingSheet` on delete a tag', async () => { - setupMockDataForStagedTagsTesting(); - render(); + renderDrawer(stagedTagsId, { + blockingSheet: true, + setBlockingSheet: mockSetBlockingSheet, + }); expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument(); expect(mockSetBlockingSheet).toHaveBeenCalledWith(false); @@ -1053,8 +637,10 @@ describe('', () => { }); it('should call `updateTags` mutation on save', async () => { - setupMockDataForStagedTagsTesting(); - render(); + const { axiosMock } = initializeMocks(); + const url = getContentTaxonomyTagsApiUrl(stagedTagsId); + axiosMock.onPut(url).reply(200); + renderDrawer(stagedTagsId); expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument(); const editTagsButton = screen.getByRole('button', { name: /edit tags/i, @@ -1066,12 +652,11 @@ describe('', () => { }); fireEvent.click(saveButton); - expect(mockMutate).toHaveBeenCalled(); + await waitFor(() => expect(axiosMock.history.put[0].url).toEqual(url)); }); it('should taxonomies must be ordered', async () => { - setupLargeMockDataForStagedTagsTesting(); - render(); + renderDrawer(largeTagsId); expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument(); // First, taxonomies with content sorted by count implicit @@ -1091,18 +676,14 @@ describe('', () => { }); it('should not show "Other tags" section', async () => { - setupMockDataForStagedTagsTesting(); - - render(); + renderDrawer(stagedTagsId); expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument(); expect(screen.queryByText('Other tags')).not.toBeInTheDocument(); }); it('should show "Other tags" section', async () => { - setupMockDataWithOtherTagsTestings(); - - render(); + renderDrawer(otherTagsId); expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument(); expect(screen.getByText('Other tags')).toBeInTheDocument(); @@ -1112,8 +693,7 @@ describe('', () => { }); it('should test delete "Other tags" and cancel', async () => { - setupMockDataWithOtherTagsTestings(); - render(); + renderDrawer(otherTagsId); expect(await screen.findByText('Taxonomy 2')).toBeInTheDocument(); // To edit mode @@ -1139,40 +719,18 @@ describe('', () => { }); it('should show Language Taxonomy', async () => { - setupMockDataLanguageTaxonomyTestings(true); - render(); + renderDrawer(languageWithTagsId); expect(await screen.findByText('Languages')).toBeInTheDocument(); }); it('should hide Language Taxonomy', async () => { - setupMockDataLanguageTaxonomyTestings(false); - render(); + renderDrawer(languageWithoutTagsId); expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument(); - expect(screen.queryByText('Languages')).not.toBeInTheDocument(); }); it('should show empty drawer message', async () => { - useContentTaxonomyTagsData.mockReturnValue({ - isSuccess: true, - data: { - taxonomies: [], - }, - }); - getTaxonomyListData.mockResolvedValue({ - results: [], - }); - useTaxonomyTagsData.mockReturnValue({ - hasMorePages: false, - canAddTag: false, - tagPages: { - isLoading: false, - isError: false, - data: [], - }, - }); - - render(); + renderDrawer(emptyTagsId); expect(await screen.findByText(/to use tags, please or contact your administrator\./i)).toBeInTheDocument(); const enableButton = screen.getByRole('button', { name: /enable a taxonomy/i, diff --git a/src/content-tags-drawer/ContentTagsDrawer.tsx b/src/content-tags-drawer/ContentTagsDrawer.tsx new file mode 100644 index 0000000000..28ab128d94 --- /dev/null +++ b/src/content-tags-drawer/ContentTagsDrawer.tsx @@ -0,0 +1,390 @@ +import React, { useContext, useEffect } from 'react'; +import { + Container, + Spinner, + Stack, + Button, + Toast, +} from '@openedx/paragon'; +import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; +import { useParams, useNavigate } from 'react-router-dom'; +import classNames from 'classnames'; +import messages from './messages'; +import ContentTagsCollapsible from './ContentTagsCollapsible'; +import Loading from '../generic/Loading'; +import useContentTagsDrawerContext from './ContentTagsDrawerHelper'; +import { ContentTagsDrawerContext, ContentTagsDrawerSheetContext } from './common/context'; + +interface TaxonomyListProps { + contentId: string; +} + +const TaxonomyList = ({ contentId }: TaxonomyListProps) => { + const navigate = useNavigate(); + const intl = useIntl(); + + const { + isTaxonomyListLoaded, + isContentTaxonomyTagsLoaded, + tagsByTaxonomy, + stagedContentTags, + collapsibleStates, + } = React.useContext(ContentTagsDrawerContext); + + if (isTaxonomyListLoaded && isContentTaxonomyTagsLoaded) { + if (tagsByTaxonomy.length !== 0) { + return ( +
+ { tagsByTaxonomy.map((data) => ( +
+ +
+
+ ))} +
+ ); + } + + return ( + navigate('/taxonomies')} + > + { intl.formatMessage(messages.emptyDrawerContentLink) } + + ), + }} + /> + ); + } + + return ; +}; + +const ContentTagsDrawerTittle = () => { + const intl = useIntl(); + const { + isContentDataLoaded, + contentName, + } = useContext(ContentTagsDrawerContext); + + return ( + <> + { isContentDataLoaded + ?

{ contentName }

+ : ( +
+ +
+ )} +
+ + ); +}; + +interface ContentTagsDrawerVariantFooterProps { + onClose: () => void, +} + +const ContentTagsDrawerVariantFooter = ({ onClose }: ContentTagsDrawerVariantFooterProps) => { + const intl = useIntl(); + const { + commitGlobalStagedTagsStatus, + commitGlobalStagedTags, + isEditMode, + toReadMode, + toEditMode, + } = useContext(ContentTagsDrawerContext); + + return ( + +
+ { commitGlobalStagedTagsStatus !== 'loading' ? ( + + + + + ) + : ( + + )} +
+
+ ); +}; + +const ContentTagsComponentVariantFooter = () => { + const intl = useIntl(); + const { + commitGlobalStagedTagsStatus, + commitGlobalStagedTags, + isEditMode, + toReadMode, + toEditMode, + } = useContext(ContentTagsDrawerContext); + + return ( +
+ {isEditMode ? ( +
+ { commitGlobalStagedTagsStatus !== 'loading' ? ( + + + + + ) : ( +
+ +
+ )} +
+ ) : ( + + )} +
+ ); +}; + +interface ContentTagsDrawerProps { + id?: string; + onClose?: () => void; + variant?: 'drawer' | 'component'; +} + +/** + * Drawer with the functionality to show and manage tags in a certain content. + * It is used both in interfaces of this MFE and in edx-platform interfaces such as iframe. + * - If you want to use it as an iframe, the component obtains the `contentId` from the url parameters. + * Functions to close the drawer are handled internally. + * TODO: We can delete this method when is no longer used on edx-platform. + * - If you want to use it as react component, you need to pass the content id and the close functions + * through the component parameters. + */ +const ContentTagsDrawer = ({ + id, + onClose, + variant = 'drawer', +}: ContentTagsDrawerProps) => { + const intl = useIntl(); + // TODO: We can delete 'params' when the iframe is no longer used on edx-platform + const params = useParams(); + const contentId = id ?? params.contentId; + + if (contentId === undefined) { + throw new Error('Error: contentId cannot be null.'); + } + + const context = useContentTagsDrawerContext(contentId); + const { blockingSheet } = useContext(ContentTagsDrawerSheetContext); + + const { + showToastAfterSave, + toReadMode, + commitGlobalStagedTagsStatus, + isTaxonomyListLoaded, + isContentTaxonomyTagsLoaded, + stagedContentTags, + collapsibleStates, + toastMessage, + closeToast, + setCollapsibleToInitalState, + otherTaxonomies, + } = context; + + let onCloseDrawer: () => void; + if (variant === 'drawer') { + if (onClose === undefined) { + onCloseDrawer = () => { + // "*" allows communication with any origin + window.parent.postMessage('closeManageTagsDrawer', '*'); + }; + } else { + onCloseDrawer = onClose; + } + } + + useEffect(() => { + if (variant === 'drawer') { + const handleEsc = (event) => { + /* Close drawer when ESC-key is pressed and selectable dropdown box not open */ + const selectableBoxOpen = document.querySelector('[data-selectable-box="taxonomy-tags"]'); + if (event.key === 'Escape' && !selectableBoxOpen && !blockingSheet) { + onCloseDrawer(); + } + }; + document.addEventListener('keydown', handleEsc); + + return () => { + document.removeEventListener('keydown', handleEsc); + }; + } + return () => {}; + }, [blockingSheet]); + + useEffect(() => { + /* istanbul ignore next */ + if (commitGlobalStagedTagsStatus === 'success') { + showToastAfterSave(); + toReadMode(); + } + }, [commitGlobalStagedTagsStatus]); + + // First call of the initial collapsible states + React.useEffect(() => { + setCollapsibleToInitalState(); + }, [isTaxonomyListLoaded, isContentTaxonomyTagsLoaded]); + + const renderFooter = () => { + if (isTaxonomyListLoaded && isContentTaxonomyTagsLoaded) { + switch (variant) { + case 'drawer': + return ; + case 'component': + return ; + default: + return null; + } + } + return null; + }; + + return ( + +
+ + {variant === 'drawer' && ( + + )} + + {variant === 'drawer' && ( +

+ {intl.formatMessage(messages.headerSubtitle)} +

+ )} + + {otherTaxonomies.length !== 0 && ( +
+

+ {intl.formatMessage(messages.otherTagsHeader)} +

+

+ {intl.formatMessage(messages.otherTagsDescription)} +

+ { isTaxonomyListLoaded && isContentTaxonomyTagsLoaded && ( + otherTaxonomies.map((data) => ( +
+ +
+
+ )) + )} +
+ )} +
+
+ {renderFooter()} + {/* istanbul ignore next */ + toastMessage && ( + + {toastMessage} + + ) + } +
+
+ ); +}; + +export default ContentTagsDrawer; diff --git a/src/content-tags-drawer/data/api.mocks.ts b/src/content-tags-drawer/data/api.mocks.ts new file mode 100644 index 0000000000..ce1d50c05c --- /dev/null +++ b/src/content-tags-drawer/data/api.mocks.ts @@ -0,0 +1,378 @@ +import * as api from './api'; +import * as taxonomyApi from '../../taxonomy/data/api'; +import { languageExportId } from '../utils'; + +/** + * Mock for `getContentTaxonomyTagsData()` + */ +export async function mockContentTaxonomyTagsData(contentId: string): Promise { + const thisMock = mockContentTaxonomyTagsData; + switch (contentId) { + case thisMock.stagedTagsId: return thisMock.stagedTags; + case thisMock.otherTagsId: return thisMock.otherTags; + case thisMock.languageWithTagsId: return thisMock.languageWithTags; + case thisMock.languageWithoutTagsId: return thisMock.languageWithoutTags; + case thisMock.largeTagsId: return thisMock.largeTags; + case thisMock.emptyTagsId: return thisMock.emptyTags; + default: throw new Error(`No mock has been set up for contentId "${contentId}"`); + } +} +mockContentTaxonomyTagsData.stagedTagsId = 'block-v1:StagedTagsOrg+STC1+2023_1+type@vertical+block@stagedTagsId'; +mockContentTaxonomyTagsData.stagedTags = { + taxonomies: [ + { + name: 'Taxonomy 1', + taxonomyId: 123, + canTagObject: true, + tags: [ + { + value: 'Tag 1', + lineage: ['Tag 1'], + canDeleteObjecttag: true, + }, + { + value: 'Tag 2', + lineage: ['Tag 2'], + canDeleteObjecttag: true, + }, + ], + }, + ], +}; +mockContentTaxonomyTagsData.otherTagsId = 'block-v1:StagedTagsOrg+STC1+2023_1+type@vertical+block@otherTagsId'; +mockContentTaxonomyTagsData.otherTags = { + taxonomies: [ + { + name: 'Taxonomy 1', + taxonomyId: 123, + canTagObject: true, + tags: [ + { + value: 'Tag 1', + lineage: ['Tag 1'], + canDeleteObjecttag: true, + }, + { + value: 'Tag 2', + lineage: ['Tag 2'], + canDeleteObjecttag: true, + }, + ], + }, + { + name: 'Taxonomy 2', + taxonomyId: 1234, + canTagObject: false, + tags: [ + { + value: 'Tag 3', + lineage: ['Tag 3'], + canDeleteObjecttag: true, + }, + { + value: 'Tag 4', + lineage: ['Tag 4'], + canDeleteObjecttag: true, + }, + ], + }, + ], +}; +mockContentTaxonomyTagsData.languageWithTagsId = 'block-v1:LanguageTagsOrg+STC1+2023_1+type@vertical+block@languageWithTagsId'; +mockContentTaxonomyTagsData.languageWithTags = { + taxonomies: [ + { + name: 'Languages', + taxonomyId: 1234, + exportId: languageExportId, + canTagObject: true, + tags: [ + { + value: 'Tag 1', + lineage: ['Tag 1'], + canDeleteObjecttag: true, + }, + ], + }, + { + name: 'Taxonomy 1', + taxonomyId: 12345, + canTagObject: true, + tags: [ + { + value: 'Tag 1', + lineage: ['Tag 1'], + canDeleteObjecttag: true, + }, + { + value: 'Tag 2', + lineage: ['Tag 2'], + canDeleteObjecttag: true, + }, + ], + }, + ], +}; +mockContentTaxonomyTagsData.languageWithoutTagsId = 'block-v1:LanguageTagsOrg+STC1+2023_1+type@vertical+block@languageWithoutTagsId'; +mockContentTaxonomyTagsData.languageWithoutTags = { + taxonomies: [ + { + name: 'Languages', + taxonomyId: 1234, + exportId: languageExportId, + canTagObject: true, + tags: [], + }, + { + name: 'Taxonomy 1', + taxonomyId: 12345, + canTagObject: true, + tags: [ + { + value: 'Tag 1', + lineage: ['Tag 1'], + canDeleteObjecttag: true, + }, + { + value: 'Tag 2', + lineage: ['Tag 2'], + canDeleteObjecttag: true, + }, + ], + }, + ], +}; +mockContentTaxonomyTagsData.largeTagsId = 'block-v1:LargeTagsOrg+STC1+2023_1+type@vertical+block@largeTagsId'; +mockContentTaxonomyTagsData.largeTags = { + taxonomies: [ + { + name: 'Taxonomy 1', + taxonomyId: 123, + canTagObject: true, + tags: [ + { + value: 'Tag 1', + lineage: ['Tag 1'], + canDeleteObjecttag: true, + }, + { + value: 'Tag 2', + lineage: ['Tag 2'], + canDeleteObjecttag: true, + }, + ], + }, + { + 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: [], + }, + ], +}; +mockContentTaxonomyTagsData.emptyTagsId = 'block-v1:EmptyTagsOrg+STC1+2023_1+type@vertical+block@emptyTagsId'; +mockContentTaxonomyTagsData.emptyTags = { + taxonomies: [], +}; +mockContentTaxonomyTagsData.applyMock = () => jest.spyOn(api, 'getContentTaxonomyTagsData').mockImplementation(mockContentTaxonomyTagsData); + +/** + * Mock for `getTaxonomyListData()` + */ +export async function mockTaxonomyListData(org: string): Promise { + const thisMock = mockTaxonomyListData; + switch (org) { + case thisMock.stagedTagsOrg: return thisMock.stagedTags; + case thisMock.languageTagsOrg: return thisMock.languageTags; + case thisMock.largeTagsOrg: return thisMock.largeTags; + case thisMock.emptyTagsOrg: return thisMock.emptyTags; + default: throw new Error(`No mock has been set up for org "${org}"`); + } +} +mockTaxonomyListData.stagedTagsOrg = 'StagedTagsOrg'; +mockTaxonomyListData.stagedTags = { + results: [ + { + id: 123, + name: 'Taxonomy 1', + description: 'This is a description 1', + canTagObject: true, + }, + ], +}; +mockTaxonomyListData.languageTagsOrg = 'LanguageTagsOrg'; +mockTaxonomyListData.languageTags = { + results: [ + { + id: 1234, + name: 'Languages', + description: 'This is a description 1', + exportId: languageExportId, + canTagObject: true, + }, + { + id: 12345, + name: 'Taxonomy 1', + description: 'This is a description 2', + canTagObject: true, + }, + ], +}; +mockTaxonomyListData.largeTagsOrg = 'LargeTagsOrg'; +mockTaxonomyListData.largeTags = { + 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, + }, + ], +}; +mockTaxonomyListData.emptyTagsOrg = 'EmptyTagsOrg'; +mockTaxonomyListData.emptyTags = { + results: [], +}; +mockTaxonomyListData.applyMock = () => jest.spyOn(taxonomyApi, 'getTaxonomyListData').mockImplementation(mockTaxonomyListData); + +/** + * Mock for `getTaxonomyTagsData()` + */ +export async function mockTaxonomyTagsData(taxonomyId: number): Promise { + const thisMock = mockTaxonomyTagsData; + switch (taxonomyId) { + case thisMock.stagedTagsTaxonomy: return thisMock.stagedTags; + case thisMock.languageTagsTaxonomy: return thisMock.languageTags; + default: throw new Error(`No mock has been set up for taxonomyId "${taxonomyId}"`); + } +} +mockTaxonomyTagsData.stagedTagsTaxonomy = 123; +mockTaxonomyTagsData.stagedTags = { + count: 3, + currentPage: 1, + next: null, + numPages: 1, + previous: null, + start: 1, + results: [ + { + value: 'Tag 1', + externalId: null, + childCount: 0, + depth: 0, + parentValue: null, + id: 12345, + subTagsUrl: null, + canChangeTag: false, + canDeleteTag: false, + }, + { + value: 'Tag 2', + externalId: null, + childCount: 0, + depth: 0, + parentValue: null, + id: 12346, + subTagsUrl: null, + canChangeTag: false, + canDeleteTag: false, + }, + { + value: 'Tag 3', + externalId: null, + childCount: 0, + depth: 0, + parentValue: null, + id: 12347, + subTagsUrl: null, + canChangeTag: false, + canDeleteTag: false, + }, + ], +}; +mockTaxonomyTagsData.languageTagsTaxonomy = 1234; +mockTaxonomyTagsData.languageTags = { + count: 1, + currentPage: 1, + next: null, + numPages: 1, + previous: null, + start: 1, + results: [{ + value: 'Tag 1', + externalId: null, + childCount: 0, + depth: 0, + parentValue: null, + id: 12345, + subTagsUrl: null, + canChangeTag: false, + canDeleteTag: false, + }], +}; +mockTaxonomyTagsData.applyMock = () => jest.spyOn(api, 'getTaxonomyTagsData').mockImplementation(mockTaxonomyTagsData); + +/** + * Mock for `getContentData()` + */ +export async function mockContentData(): Promise { + return mockContentData.data; +} +mockContentData.data = { + displayName: 'Unit 1', +}; +mockContentData.applyMock = () => jest.spyOn(api, 'getContentData').mockImplementation(mockContentData); diff --git a/src/content-tags-drawer/data/apiHooks.jsx b/src/content-tags-drawer/data/apiHooks.jsx index a9e09ae85e..34f70bb3f4 100644 --- a/src/content-tags-drawer/data/apiHooks.jsx +++ b/src/content-tags-drawer/data/apiHooks.jsx @@ -14,6 +14,7 @@ import { updateContentTaxonomyTags, getContentTaxonomyTagsCount, } from './api'; +import { libraryQueryPredicate, xblockQueryKeys } from '../../library-authoring/data/apiHooks'; /** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagListData} TagListData */ /** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagData} TagData */ @@ -146,6 +147,14 @@ export const useContentTaxonomyTagsUpdater = (contentId) => { contentPattern = contentId.replace(/\+type@.*$/, '*'); } queryClient.invalidateQueries({ queryKey: ['contentTagsCount', contentPattern] }); + if (contentId.includes('lb:')) { + // Obtain library id from contentId + const libraryId = ['lib', ...contentId.split(':').slice(1, 3)].join(':'); + // Invalidate component metadata to update tags count + queryClient.invalidateQueries(xblockQueryKeys.componentMetadata(contentId)); + // Invalidate content search to update tags count + queryClient.invalidateQueries(['content_search'], { predicate: (query) => libraryQueryPredicate(query, libraryId) }); + } }, onSuccess: /* istanbul ignore next */ () => { /* istanbul ignore next */ diff --git a/src/editors/containers/ProblemEditor/data/OLXParser.js b/src/editors/containers/ProblemEditor/data/OLXParser.js index 8a5e290507..0ac8664bf8 100644 --- a/src/editors/containers/ProblemEditor/data/OLXParser.js +++ b/src/editors/containers/ProblemEditor/data/OLXParser.js @@ -664,7 +664,7 @@ export class OLXParser { } if (Object.keys(this.problem).some((key) => key.indexOf('@_') !== -1 && !settingsOlxAttributes.includes(key))) { - throw new Error('Misc Attributes asscoiated with problem, opening in advanced editor'); + throw new Error('Misc Attributes associated with problem, opening in advanced editor'); } const problemType = this.getProblemType(); diff --git a/src/editors/containers/ProblemEditor/data/OLXParser.test.js b/src/editors/containers/ProblemEditor/data/OLXParser.test.js index 1d8cc8d5af..4a6d7fb256 100644 --- a/src/editors/containers/ProblemEditor/data/OLXParser.test.js +++ b/src/editors/containers/ProblemEditor/data/OLXParser.test.js @@ -58,7 +58,7 @@ describe('OLXParser', () => { labelDescriptionQuestionOlxParser.getParsedOLXData(); } catch (e) { expect(e).toBeInstanceOf(Error); - expect(e.message).toBe('Misc Attributes asscoiated with problem, opening in advanced editor'); + expect(e.message).toBe('Misc Attributes associated with problem, opening in advanced editor'); } }); }); diff --git a/src/editors/data/constants/problem.ts b/src/editors/data/constants/problem.ts index e0cb91d593..230eb57515 100644 --- a/src/editors/data/constants/problem.ts +++ b/src/editors/data/constants/problem.ts @@ -227,7 +227,7 @@ export const RichTextProblems = [ProblemTypeKeys.SINGLESELECT, ProblemTypeKeys.M export const settingsOlxAttributes = [ '@_display_name', '@_weight', - '@_max_atempts', + '@_max_attempts', '@_showanswer', '@_show_reset_button', '@_submission_wait_seconds', diff --git a/src/editors/data/services/cms/api.test.ts b/src/editors/data/services/cms/api.test.ts index 656fb4db49..d7b553fb96 100644 --- a/src/editors/data/services/cms/api.test.ts +++ b/src/editors/data/services/cms/api.test.ts @@ -69,7 +69,7 @@ describe('cms api', () => { it('should call get with normal accept header for prod', async () => { process.env.NODE_ENV = 'production'; - process.env.MFE_NAME = 'frontend-app-library-authoring'; + process.env.MFE_NAME = 'frontend-app-course-authoring'; // eslint-disable-next-line no-shadow, @typescript-eslint/no-shadow const { apiMethods } = await import('./api'); // eslint-disable-next-line no-shadow, @typescript-eslint/no-shadow @@ -90,18 +90,6 @@ describe('cms api', () => { apiMethods.fetchByUnitId({ blockId, studioEndpointUrl }); expect(getSpy).toHaveBeenCalledWith(urls.blockAncestor({ studioEndpointUrl, blockId }), {}); }); - - it('should call get with special accept header "*/*" for course-authoring', async () => { - process.env.NODE_ENV = 'development'; - process.env.MFE_NAME = 'frontend-app-library-authoring'; - // eslint-disable-next-line no-shadow, @typescript-eslint/no-shadow - const { apiMethods } = await import('./api'); - // eslint-disable-next-line no-shadow, @typescript-eslint/no-shadow - const utils = await import('./utils'); - const getSpy = jest.spyOn(utils, 'get'); - apiMethods.fetchByUnitId({ blockId, studioEndpointUrl }); - expect(getSpy).toHaveBeenCalledWith(urls.blockAncestor({ studioEndpointUrl, blockId }), { headers: { Accept: '*/*' } }); - }); }); }); diff --git a/src/editors/data/services/cms/api.ts b/src/editors/data/services/cms/api.ts index bce827b5ae..673309bb00 100644 --- a/src/editors/data/services/cms/api.ts +++ b/src/editors/data/services/cms/api.ts @@ -7,14 +7,6 @@ import { durationStringFromValue } from '../../../containers/VideoEditor/compone const fetchByUnitIdOptions: AxiosRequestConfig = {}; -// For some reason, the local webpack-dev-server of library-authoring does not accept the normal Accept header. -// This is a workaround only for that specific case; the idea is to only do this locally and only for library-authoring. -if (process.env.NODE_ENV === 'development' && process.env.MFE_NAME === 'frontend-app-library-authoring') { - fetchByUnitIdOptions.headers = { - Accept: '*/*', - }; -} - interface Pagination { start: number; end: number; diff --git a/src/editors/sharedComponents/TypeaheadDropdown/index.jsx b/src/editors/sharedComponents/TypeaheadDropdown/index.jsx index 23e0ea1a77..5097981b75 100644 --- a/src/editors/sharedComponents/TypeaheadDropdown/index.jsx +++ b/src/editors/sharedComponents/TypeaheadDropdown/index.jsx @@ -90,7 +90,7 @@ class TypeaheadDropdown extends React.Component { this.setValue(opt); this.setState({ displayValue: opt }); } else { - this.setValue(''); + this.setValue(normalized); this.setState({ displayValue: value }); } } diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index ccf398723b..8a439463dd 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -11,12 +11,18 @@ import { } from '../testUtils'; import mockResult from './__mocks__/library-search.json'; import mockEmptyResult from '../search-modal/__mocks__/empty-search-result.json'; -import { mockContentLibrary, mockLibraryBlockTypes, mockXBlockFields } from './data/api.mocks'; +import { + mockContentLibrary, + mockGetCollectionMetadata, + mockLibraryBlockTypes, + mockXBlockFields, +} from './data/api.mocks'; import { mockContentSearchConfig } from '../search-manager/data/api.mock'; import { mockBroadcastChannel } from '../generic/data/api.mock'; import { LibraryLayout } from '.'; import { getLibraryCollectionsApiUrl } from './data/api'; +mockGetCollectionMetadata.applyMock(); mockContentSearchConfig.applyMock(); mockContentLibrary.applyMock(); mockLibraryBlockTypes.applyMock(); @@ -458,6 +464,25 @@ describe('', () => { await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument()); }); + it('should open and close the collection sidebar', async () => { + await renderLibraryPage(); + + // Click on the first component. It could appear twice, in both "Recently Modified" and "Collections" + fireEvent.click((await screen.findAllByText('Collection 1'))[0]); + + const sidebar = screen.getByTestId('library-sidebar'); + + const { getByRole, getByText } = within(sidebar); + + // The mock data for the sidebar has a title of "Test Collection" + await waitFor(() => expect(getByText('Test Collection')).toBeInTheDocument()); + + const closeButton = getByRole('button', { name: /close/i }); + fireEvent.click(closeButton); + + await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument()); + }); + it('can filter by capa problem type', async () => { const problemTypes = { 'Multiple Choice': 'choiceresponse', diff --git a/src/library-authoring/__mocks__/collection-search.json b/src/library-authoring/__mocks__/collection-search.json index 3033e3c36a..9785489dbb 100644 --- a/src/library-authoring/__mocks__/collection-search.json +++ b/src/library-authoring/__mocks__/collection-search.json @@ -200,7 +200,7 @@ } ], "created": 1726740779.564664, - "modified": 1726740811.684142, + "modified": 1726840811.684142, "usage_key": "lib-collection:OpenedX:CSPROB2:collection-from-meilisearch", "context_key": "lib:OpenedX:CSPROB2", "org": "OpenedX", diff --git a/src/library-authoring/__mocks__/library-search.json b/src/library-authoring/__mocks__/library-search.json index f84d16c611..72ea474fdd 100644 --- a/src/library-authoring/__mocks__/library-search.json +++ b/src/library-authoring/__mocks__/library-search.json @@ -284,6 +284,7 @@ "hits": [ { "display_name": "Collection 1", + "block_id": "col1", "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer.", "id": 1, "type": "collection", diff --git a/src/library-authoring/collections/CollectionDetails.test.tsx b/src/library-authoring/collections/CollectionDetails.test.tsx new file mode 100644 index 0000000000..5c4e69064e --- /dev/null +++ b/src/library-authoring/collections/CollectionDetails.test.tsx @@ -0,0 +1,161 @@ +import type MockAdapter from 'axios-mock-adapter'; +import fetchMock from 'fetch-mock-jest'; + +import { mockContentSearchConfig, mockGetBlockTypes } from '../../search-manager/data/api.mock'; +import { + initializeMocks, + fireEvent, + render, + screen, + waitFor, + within, +} from '../../testUtils'; +import * as api from '../data/api'; +import { mockContentLibrary, mockGetCollectionMetadata } from '../data/api.mocks'; +import CollectionDetails from './CollectionDetails'; + +let axiosMock: MockAdapter; +let mockShowToast: (message: string) => void; + +mockGetCollectionMetadata.applyMock(); +mockContentSearchConfig.applyMock(); +mockGetBlockTypes.applyMock(); + +const { collectionId } = mockGetCollectionMetadata; +const { description: originalDescription } = mockGetCollectionMetadata.collectionData; + +const library = mockContentLibrary.libraryData; + +describe('', () => { + beforeEach(() => { + const mocks = initializeMocks(); + axiosMock = mocks.axiosMock; + mockShowToast = mocks.mockShowToast; + }); + + afterEach(() => { + jest.clearAllMocks(); + axiosMock.restore(); + fetchMock.mockReset(); + }); + + it('should render Collection Details', async () => { + render(); + + // Collection Description + expect(await screen.findByText('Description / Card Preview Text')).toBeInTheDocument(); + expect(screen.getByText(originalDescription)).toBeInTheDocument(); + + // Collection History + expect(screen.getByText('Collection History')).toBeInTheDocument(); + // Modified date + expect(screen.getByText('September 20, 2024')).toBeInTheDocument(); + // Created date + expect(screen.getByText('September 19, 2024')).toBeInTheDocument(); + }); + + it('should allow modifying the description', async () => { + render(); + expect(await screen.findByText('Description / Card Preview Text')).toBeInTheDocument(); + + expect(screen.getByText(originalDescription)).toBeInTheDocument(); + + const url = api.getLibraryCollectionApiUrl(library.id, collectionId); + axiosMock.onPatch(url).reply(200); + + const textArea = screen.getByRole('textbox'); + + // Change the description to the same value + fireEvent.focus(textArea); + fireEvent.change(textArea, { target: { value: originalDescription } }); + fireEvent.blur(textArea); + + await waitFor(() => { + expect(axiosMock.history.patch).toHaveLength(0); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + // Change the description to a new value + fireEvent.focus(textArea); + fireEvent.change(textArea, { target: { value: 'New description' } }); + fireEvent.blur(textArea); + + await waitFor(() => { + expect(axiosMock.history.patch).toHaveLength(1); + expect(axiosMock.history.patch[0].url).toEqual(url); + expect(axiosMock.history.patch[0].data).toEqual(JSON.stringify({ description: 'New description' })); + expect(mockShowToast).toHaveBeenCalledWith('Collection updated successfully.'); + }); + }); + + it('should show error while modifing the description', async () => { + render(); + expect(await screen.findByText('Description / Card Preview Text')).toBeInTheDocument(); + + expect(screen.getByText(originalDescription)).toBeInTheDocument(); + + const url = api.getLibraryCollectionApiUrl(library.id, collectionId); + axiosMock.onPatch(url).reply(500); + + const textArea = screen.getByRole('textbox'); + + // Change the description to a new value + fireEvent.focus(textArea); + fireEvent.change(textArea, { target: { value: 'New description' } }); + fireEvent.blur(textArea); + + await waitFor(() => { + expect(axiosMock.history.patch).toHaveLength(1); + expect(axiosMock.history.patch[0].url).toEqual(url); + expect(axiosMock.history.patch[0].data).toEqual(JSON.stringify({ description: 'New description' })); + expect(mockShowToast).toHaveBeenCalledWith('Failed to update collection.'); + }); + }); + + it('should render Collection stats', async () => { + mockGetBlockTypes('someBlocks'); + render(); + expect(await screen.findByText('Description / Card Preview Text')).toBeInTheDocument(); + + expect(screen.getByText('Collection Stats')).toBeInTheDocument(); + expect(await screen.findByText('Total')).toBeInTheDocument(); + + [ + { blockType: 'Total', count: 3 }, + { blockType: 'Text', count: 2 }, + { blockType: 'Problem', count: 1 }, + ].forEach(({ blockType, count }) => { + const blockCount = screen.getByText(blockType).closest('div') as HTMLDivElement; + expect(within(blockCount).getByText(count.toString())).toBeInTheDocument(); + }); + }); + + it('should render Collection stats for empty collection', async () => { + mockGetBlockTypes('noBlocks'); + render(); + expect(await screen.findByText('Description / Card Preview Text')).toBeInTheDocument(); + + expect(screen.getByText('Collection Stats')).toBeInTheDocument(); + expect(await screen.findByText('This collection is currently empty.')).toBeInTheDocument(); + }); + + it('should render Collection stats for big collection', async () => { + mockGetBlockTypes('moreBlocks'); + render(); + expect(await screen.findByText('Description / Card Preview Text')).toBeInTheDocument(); + + expect(screen.getByText('Collection Stats')).toBeInTheDocument(); + expect(await screen.findByText('36')).toBeInTheDocument(); + + [ + { blockType: 'Total', count: 36 }, + { blockType: 'Video', count: 8 }, + { blockType: 'Problem', count: 7 }, + { blockType: 'Text', count: 6 }, + { blockType: 'Other', count: 15 }, + ].forEach(({ blockType, count }) => { + const blockCount = screen.getByText(blockType).closest('div') as HTMLDivElement; + expect(within(blockCount).getByText(count.toString())).toBeInTheDocument(); + }); + }); +}); diff --git a/src/library-authoring/collections/CollectionDetails.tsx b/src/library-authoring/collections/CollectionDetails.tsx new file mode 100644 index 0000000000..9936177902 --- /dev/null +++ b/src/library-authoring/collections/CollectionDetails.tsx @@ -0,0 +1,177 @@ +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { Icon, Stack } from '@openedx/paragon'; +import { useContext, useEffect, useState } from 'react'; +import classNames from 'classnames'; + +import { getItemIcon } from '../../generic/block-type-utils'; +import { ToastContext } from '../../generic/toast-context'; +import { BlockTypeLabel, useGetBlockTypes } from '../../search-manager'; +import type { ContentLibrary } from '../data/api'; +import { useCollection, useUpdateCollection } from '../data/apiHooks'; +import HistoryWidget from '../generic/history-widget'; +import messages from './messages'; + +interface BlockCountProps { + count: number, + blockType?: string, + label: React.ReactNode, + className?: string, +} + +const BlockCount = ({ + count, + blockType, + label, + className, +}: BlockCountProps) => { + const icon = blockType && getItemIcon(blockType); + return ( + + {label} + + {icon && } + {count} + + + ); +}; + +interface CollectionStatsWidgetProps { + libraryId: string, + collectionId: string, +} + +const CollectionStatsWidget = ({ libraryId, collectionId }: CollectionStatsWidgetProps) => { + const { data: blockTypes } = useGetBlockTypes([ + `context_key = "${libraryId}"`, + `collections.key = "${collectionId}"`, + ]); + + if (!blockTypes) { + return null; + } + + const blockTypesArray = Object.entries(blockTypes) + .map(([blockType, count]) => ({ blockType, count })) + .sort((a, b) => b.count - a.count); + + const totalBlocksCount = blockTypesArray.reduce((acc, { count }) => acc + count, 0); + // Show the top 3 block type counts individually, and splice the remaining block types together under "Other". + const numBlockTypesShown = 3; + const otherBlocks = blockTypesArray.splice(numBlockTypesShown); + const otherBlocksCount = otherBlocks.reduce((acc, { count }) => acc + count, 0); + + if (totalBlocksCount === 0) { + return ( +
+ +
+ ); + } + + return ( + + } + count={totalBlocksCount} + className="border-right" + /> + {blockTypesArray.map(({ blockType, count }) => ( + } + blockType={blockType} + count={count} + /> + ))} + {otherBlocks.length > 0 && ( + } + count={otherBlocksCount} + /> + )} + + ); +}; + +interface CollectionDetailsProps { + library: ContentLibrary, + collectionId: string, +} + +const CollectionDetails = ({ library, collectionId }: CollectionDetailsProps) => { + const intl = useIntl(); + const { showToast } = useContext(ToastContext); + + const updateMutation = useUpdateCollection(library.id, collectionId); + const { data: collection } = useCollection(library.id, collectionId); + + const [description, setDescription] = useState(collection?.description || ''); + + useEffect(() => { + if (collection) { + setDescription(collection.description); + } + }, [collection]); + + if (!collection) { + return null; + } + + const onSubmit = (e: React.FocusEvent) => { + const newDescription = e.target.value; + if (newDescription === collection.description) { + return; + } + updateMutation.mutateAsync({ + description: newDescription, + }).then(() => { + showToast(intl.formatMessage(messages.updateCollectionSuccessMsg)); + }).catch(() => { + showToast(intl.formatMessage(messages.updateCollectionErrorMsg)); + }); + }; + + return ( + +
+

+ {intl.formatMessage(messages.detailsTabDescriptionTitle)} +

+ {library.canEditLibrary ? ( +