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 }) => (
-
- )}
-
+
+
);
};
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' ? (
-
-
- { intl.formatMessage(isEditMode
- ? messages.tagsDrawerCancelButtonText
- : messages.tagsDrawerCloseButtonText)}
-
-
- { intl.formatMessage(isEditMode
- ? messages.tagsDrawerSaveButtonText
- : messages.tagsDrawerEditTagsButtonText)}
-
-
- )
- : (
-
- )}
-
-
- )}
- {/* 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' ? (
+
+
+ { intl.formatMessage(isEditMode
+ ? messages.tagsDrawerCancelButtonText
+ : messages.tagsDrawerCloseButtonText)}
+
+
+ { intl.formatMessage(isEditMode
+ ? messages.tagsDrawerSaveButtonText
+ : messages.tagsDrawerEditTagsButtonText)}
+
+
+ )
+ : (
+
+ )}
+
+
+ );
+};
+
+const ContentTagsComponentVariantFooter = () => {
+ const intl = useIntl();
+ const {
+ commitGlobalStagedTagsStatus,
+ commitGlobalStagedTags,
+ isEditMode,
+ toReadMode,
+ toEditMode,
+ } = useContext(ContentTagsDrawerContext);
+
+ return (
+
+ {isEditMode ? (
+
+ { commitGlobalStagedTagsStatus !== 'loading' ? (
+
+
+ {intl.formatMessage(messages.tagsDrawerCancelButtonText)}
+
+
+ {intl.formatMessage(messages.tagsDrawerSaveButtonText)}
+
+
+ ) : (
+
+
+
+ )}
+
+ ) : (
+
+ {intl.formatMessage(messages.manageTagsButton)}
+
+ )}
+
+ );
+};
+
+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 ? (
+
+
+
+ {intl.formatMessage(messages.detailsTabStatsTitle)}
+
+
+
+
+
+
+ {intl.formatMessage(messages.detailsTabHistoryTitle)}
+
+
+
+
+ );
+};
+
+export default CollectionDetails;
diff --git a/src/library-authoring/collections/CollectionInfo.tsx b/src/library-authoring/collections/CollectionInfo.tsx
index 4c1f40e732..b13d5c5d3d 100644
--- a/src/library-authoring/collections/CollectionInfo.tsx
+++ b/src/library-authoring/collections/CollectionInfo.tsx
@@ -1,27 +1,57 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import {
+ Button,
+ Stack,
Tab,
Tabs,
} from '@openedx/paragon';
+import { Link, useMatch } from 'react-router-dom';
+import type { ContentLibrary } from '../data/api';
+import CollectionDetails from './CollectionDetails';
import messages from './messages';
-const CollectionInfo = () => {
+interface CollectionInfoProps {
+ library: ContentLibrary,
+ collectionId: string,
+}
+
+const CollectionInfo = ({ library, collectionId }: CollectionInfoProps) => {
const intl = useIntl();
+ const url = `/library/${library.id}/collection/${collectionId}/`;
+ const urlMatch = useMatch(url);
return (
-
-
- Manage tab placeholder
-
-
- Details tab placeholder
-
-
+
+ {!urlMatch && (
+
+
+ {intl.formatMessage(messages.openCollectionButton)}
+
+
+ )}
+
+
+ Manage tab placeholder
+
+
+
+
+
+
);
};
diff --git a/src/library-authoring/collections/CollectionInfoHeader.test.tsx b/src/library-authoring/collections/CollectionInfoHeader.test.tsx
new file mode 100644
index 0000000000..47dfe07aa1
--- /dev/null
+++ b/src/library-authoring/collections/CollectionInfoHeader.test.tsx
@@ -0,0 +1,157 @@
+import type MockAdapter from 'axios-mock-adapter';
+import userEvent from '@testing-library/user-event';
+
+import {
+ initializeMocks,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '../../testUtils';
+import { mockContentLibrary, mockGetCollectionMetadata } from '../data/api.mocks';
+import * as api from '../data/api';
+import CollectionInfoHeader from './CollectionInfoHeader';
+
+let axiosMock: MockAdapter;
+let mockShowToast: (message: string) => void;
+
+mockGetCollectionMetadata.applyMock();
+
+const { collectionId } = mockGetCollectionMetadata;
+
+describe(' ', () => {
+ beforeEach(() => {
+ const mocks = initializeMocks();
+ axiosMock = mocks.axiosMock;
+ mockShowToast = mocks.mockShowToast;
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ axiosMock.restore();
+ });
+
+ it('should render Collection info Header', async () => {
+ const library = await mockContentLibrary(mockContentLibrary.libraryId);
+ render( );
+ expect(await screen.findByText('Test Collection')).toBeInTheDocument();
+
+ expect(screen.getByRole('button', { name: /edit collection title/i })).toBeInTheDocument();
+ });
+
+ it('should not render edit title button without permission', async () => {
+ const readOnlyLibrary = await mockContentLibrary(mockContentLibrary.libraryIdReadOnly);
+ render( );
+ expect(await screen.findByText('Test Collection')).toBeInTheDocument();
+
+ expect(screen.queryByRole('button', { name: /edit collection title/i })).not.toBeInTheDocument();
+ });
+
+ it('should update collection title', async () => {
+ const library = await mockContentLibrary(mockContentLibrary.libraryId);
+ render( );
+ expect(await screen.findByText('Test Collection')).toBeInTheDocument();
+
+ const url = api.getLibraryCollectionApiUrl(library.id, collectionId);
+ axiosMock.onPatch(url).reply(200);
+
+ fireEvent.click(screen.getByRole('button', { name: /edit collection title/i }));
+
+ const textBox = screen.getByRole('textbox', { name: /title input/i });
+
+ userEvent.clear(textBox);
+ userEvent.type(textBox, 'New Collection Title{enter}');
+
+ await waitFor(() => {
+ expect(axiosMock.history.patch[0].url).toEqual(url);
+ expect(axiosMock.history.patch[0].data).toEqual(JSON.stringify({ title: 'New Collection Title' }));
+ });
+
+ expect(textBox).not.toBeInTheDocument();
+ expect(mockShowToast).toHaveBeenCalledWith('Collection updated successfully.');
+ });
+
+ it('should not update collection title if title is the same', async () => {
+ const library = await mockContentLibrary(mockContentLibrary.libraryId);
+ render( );
+ expect(await screen.findByText('Test Collection')).toBeInTheDocument();
+
+ const url = api.getLibraryCollectionApiUrl(library.id, collectionId);
+ axiosMock.onPatch(url).reply(200);
+
+ fireEvent.click(screen.getByRole('button', { name: /edit collection title/i }));
+
+ const textBox = screen.getByRole('textbox', { name: /title input/i });
+
+ userEvent.clear(textBox);
+ userEvent.type(textBox, `${mockGetCollectionMetadata.collectionData.title}{enter}`);
+
+ await waitFor(() => expect(axiosMock.history.patch.length).toEqual(0));
+
+ expect(textBox).not.toBeInTheDocument();
+ });
+
+ it('should not update collection title if title is empty', async () => {
+ const library = await mockContentLibrary(mockContentLibrary.libraryId);
+ render( );
+ expect(await screen.findByText('Test Collection')).toBeInTheDocument();
+
+ const url = api.getLibraryCollectionApiUrl(library.id, collectionId);
+ axiosMock.onPatch(url).reply(200);
+
+ fireEvent.click(screen.getByRole('button', { name: /edit collection title/i }));
+
+ const textBox = screen.getByRole('textbox', { name: /title input/i });
+
+ userEvent.clear(textBox);
+ userEvent.type(textBox, '{enter}');
+
+ await waitFor(() => expect(axiosMock.history.patch.length).toEqual(0));
+
+ expect(textBox).not.toBeInTheDocument();
+ });
+
+ it('should close edit collection title on press Escape', async () => {
+ const library = await mockContentLibrary(mockContentLibrary.libraryId);
+ render( );
+ expect(await screen.findByText('Test Collection')).toBeInTheDocument();
+
+ const url = api.getLibraryCollectionApiUrl(library.id, collectionId);
+ axiosMock.onPatch(url).reply(200);
+
+ fireEvent.click(screen.getByRole('button', { name: /edit collection title/i }));
+
+ const textBox = screen.getByRole('textbox', { name: /title input/i });
+
+ userEvent.clear(textBox);
+ userEvent.type(textBox, 'New Collection Title{esc}');
+
+ await waitFor(() => expect(axiosMock.history.patch.length).toEqual(0));
+
+ expect(textBox).not.toBeInTheDocument();
+ });
+
+ it('should show error on edit collection title', async () => {
+ const library = await mockContentLibrary(mockContentLibrary.libraryId);
+ render( );
+ expect(await screen.findByText('Test Collection')).toBeInTheDocument();
+
+ const url = api.getLibraryCollectionApiUrl(library.id, collectionId);
+ axiosMock.onPatch(url).reply(500);
+
+ fireEvent.click(screen.getByRole('button', { name: /edit collection title/i }));
+
+ const textBox = screen.getByRole('textbox', { name: /title input/i });
+
+ userEvent.clear(textBox);
+ userEvent.type(textBox, 'New Collection Title{enter}');
+
+ await waitFor(() => {
+ expect(axiosMock.history.patch[0].url).toEqual(url);
+ expect(axiosMock.history.patch[0].data).toEqual(JSON.stringify({ title: 'New Collection Title' }));
+ });
+
+ expect(textBox).not.toBeInTheDocument();
+ expect(mockShowToast).toHaveBeenCalledWith('Failed to update collection.');
+ });
+});
diff --git a/src/library-authoring/collections/CollectionInfoHeader.tsx b/src/library-authoring/collections/CollectionInfoHeader.tsx
index fda3f42eb9..1d6d9cfbe5 100644
--- a/src/library-authoring/collections/CollectionInfoHeader.tsx
+++ b/src/library-authoring/collections/CollectionInfoHeader.tsx
@@ -1,13 +1,101 @@
-import { type CollectionHit } from '../../search-manager/data/api';
+import React, { useState, useContext, useCallback } from 'react';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import {
+ Icon,
+ IconButton,
+ Stack,
+ Form,
+} from '@openedx/paragon';
+import { Edit } from '@openedx/paragon/icons';
+
+import { ToastContext } from '../../generic/toast-context';
+import type { ContentLibrary } from '../data/api';
+import { useCollection, useUpdateCollection } from '../data/apiHooks';
+import messages from './messages';
interface CollectionInfoHeaderProps {
- collection?: CollectionHit;
+ library: ContentLibrary;
+ collectionId: string;
}
-const CollectionInfoHeader = ({ collection } : CollectionInfoHeaderProps) => (
-
- {collection?.displayName}
-
-);
+const CollectionInfoHeader = ({ library, collectionId }: CollectionInfoHeaderProps) => {
+ const intl = useIntl();
+ const [inputIsActive, setIsActive] = useState(false);
+
+ const { data: collection } = useCollection(library.id, collectionId);
+
+ const updateMutation = useUpdateCollection(library.id, collectionId);
+ const { showToast } = useContext(ToastContext);
+
+ const handleSaveDisplayName = useCallback(
+ (event) => {
+ const newTitle = event.target.value;
+ if (newTitle && newTitle !== collection?.title) {
+ updateMutation.mutateAsync({
+ title: newTitle,
+ }).then(() => {
+ showToast(intl.formatMessage(messages.updateCollectionSuccessMsg));
+ }).catch(() => {
+ showToast(intl.formatMessage(messages.updateCollectionErrorMsg));
+ }).finally(() => {
+ setIsActive(false);
+ });
+ } else {
+ setIsActive(false);
+ }
+ },
+ [collection, showToast, intl],
+ );
+
+ if (!collection) {
+ return null;
+ }
+
+ const handleClick = () => {
+ setIsActive(true);
+ };
+
+ const handleOnKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === 'Enter') {
+ handleSaveDisplayName(event);
+ } else if (event.key === 'Escape') {
+ setIsActive(false);
+ }
+ };
+
+ return (
+
+ {inputIsActive
+ ? (
+
+ )
+ : (
+ <>
+
+ {collection.title}
+
+ {library.canEditLibrary && (
+
+ )}
+ >
+ )}
+
+ );
+};
export default CollectionInfoHeader;
diff --git a/src/library-authoring/collections/LibraryCollectionPage.test.tsx b/src/library-authoring/collections/LibraryCollectionPage.test.tsx
index 129fd2fadb..af9f794a8d 100644
--- a/src/library-authoring/collections/LibraryCollectionPage.test.tsx
+++ b/src/library-authoring/collections/LibraryCollectionPage.test.tsx
@@ -10,13 +10,19 @@ import {
} from '../../testUtils';
import mockResult from '../__mocks__/collection-search.json';
import {
- mockContentLibrary, mockLibraryBlockTypes, mockXBlockFields,
+ mockContentLibrary,
+ mockLibraryBlockTypes,
+ mockXBlockFields,
+ mockGetCollectionMetadata,
} from '../data/api.mocks';
-import { mockContentSearchConfig } from '../../search-manager/data/api.mock';
-import { mockBroadcastChannel } from '../../generic/data/api.mock';
+import { mockContentSearchConfig, mockGetBlockTypes } from '../../search-manager/data/api.mock';
+import { mockBroadcastChannel, mockClipboardEmpty } from '../../generic/data/api.mock';
import { LibraryLayout } from '..';
+mockClipboardEmpty.applyMock();
+mockGetCollectionMetadata.applyMock();
mockContentSearchConfig.applyMock();
+mockGetBlockTypes.applyMock();
mockContentLibrary.applyMock();
mockLibraryBlockTypes.applyMock();
mockXBlockFields.applyMock();
@@ -28,14 +34,16 @@ const libraryTitle = mockContentLibrary.libraryData.title;
const mockCollection = {
collectionId: mockResult.results[2].hits[0].block_id,
collectionNeverLoads: 'collection-always-loading',
- collectionEmpty: 'collection-no-data',
collectionNoComponents: 'collection-no-components',
- title: mockResult.results[2].hits[0].display_name,
+ collectionEmpty: mockGetCollectionMetadata.collectionIdError,
};
+const { title } = mockGetCollectionMetadata.collectionData;
+
describe(' ', () => {
beforeEach(() => {
initializeMocks();
+ fetchMock.mockReset();
// The Meilisearch client-side API uses fetch, not Axios.
fetchMock.post(searchEndpoint, (_url, req) => {
@@ -50,7 +58,7 @@ describe(' ', () => {
// And fake the required '_formatted' fields; it contains the highlighting ... around matched words
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
mockResultCopy.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
- const collectionQueryId = requestData?.queries[2]?.filter[2]?.split('block_id = "')[1].split('"')[0];
+ const collectionQueryId = requestData?.queries[0]?.filter?.[3]?.split('collections.key = "')[1].split('"')[0];
switch (collectionQueryId) {
case mockCollection.collectionNeverLoads:
return new Promise(() => {});
@@ -73,7 +81,6 @@ describe(' ', () => {
afterEach(() => {
jest.clearAllMocks();
- fetchMock.mockReset();
});
const renderLibraryCollectionPage = async (collectionId?: string, libraryId?: string) => {
@@ -86,7 +93,7 @@ describe(' ', () => {
},
});
- if (colId !== mockCollection.collectionNeverLoads) {
+ if (![mockCollection.collectionNeverLoads, mockCollection.collectionEmpty].includes(colId)) {
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
}
};
@@ -101,20 +108,18 @@ describe(' ', () => {
it('shows an error component if no collection returned', async () => {
// This mock will simulate incorrect collection id
await renderLibraryCollectionPage(mockCollection.collectionEmpty);
- screen.debug();
- expect(await screen.findByTestId('notFoundAlert')).toBeInTheDocument();
+ expect(await screen.findByText(/Mocked request failed with status code 400./)).toBeInTheDocument();
});
it('shows collection data', async () => {
await renderLibraryCollectionPage();
expect(await screen.findByText('All Collections')).toBeInTheDocument();
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
- expect((await screen.findAllByText(mockCollection.title))[0]).toBeInTheDocument();
-
- expect(screen.queryByText('This collection is currently empty.')).not.toBeInTheDocument();
+ expect((await screen.findAllByText(title))[0]).toBeInTheDocument();
// "Recently Modified" sort shown
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
+
expect((await screen.findAllByText('Introduction to Testing'))[0]).toBeInTheDocument();
// Content header with count
expect(await screen.findByText('Content (5)')).toBeInTheDocument();
@@ -125,9 +130,9 @@ describe(' ', () => {
expect(await screen.findByText('All Collections')).toBeInTheDocument();
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
- expect((await screen.findAllByText(mockCollection.title))[0]).toBeInTheDocument();
+ expect((await screen.findAllByText(title))[0]).toBeInTheDocument();
- expect(screen.getByText('This collection is currently empty.')).toBeInTheDocument();
+ expect(screen.getAllByText('This collection is currently empty.').length).toEqual(2);
const addComponentButton = screen.getAllByRole('button', { name: /new/i })[1];
fireEvent.click(addComponentButton);
@@ -150,7 +155,10 @@ describe(' ', () => {
await renderLibraryCollectionPage(mockCollection.collectionNoComponents, libraryId);
expect(await screen.findByText('All Collections')).toBeInTheDocument();
- expect(screen.getByText('This collection is currently empty.')).toBeInTheDocument();
+
+ // Show in the collection page and in the sidebar
+ expect(screen.getAllByText('This collection is currently empty.').length).toEqual(2);
+
expect(screen.queryByRole('button', { name: /new/i })).not.toBeInTheDocument();
expect(screen.getByText('Read Only')).toBeInTheDocument();
});
@@ -161,7 +169,7 @@ describe(' ', () => {
expect(await screen.findByText('All Collections')).toBeInTheDocument();
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
- expect((await screen.findAllByText(mockCollection.title))[0]).toBeInTheDocument();
+ expect((await screen.findAllByText(title))[0]).toBeInTheDocument();
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'noresults' } });
@@ -194,8 +202,8 @@ describe(' ', () => {
expect(await screen.findByText('All Collections')).toBeInTheDocument();
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
- expect((await screen.findAllByText(mockCollection.title))[0]).toBeInTheDocument();
- expect((await screen.findAllByText(mockCollection.title))[1]).toBeInTheDocument();
+ expect((await screen.findAllByText(title))[0]).toBeInTheDocument();
+ expect((await screen.findAllByText(title))[1]).toBeInTheDocument();
expect(screen.getByText('Manage')).toBeInTheDocument();
expect(screen.getByText('Details')).toBeInTheDocument();
@@ -206,8 +214,8 @@ describe(' ', () => {
expect(await screen.findByText('All Collections')).toBeInTheDocument();
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
- expect((await screen.findAllByText(mockCollection.title))[0]).toBeInTheDocument();
- expect((await screen.findAllByText(mockCollection.title))[1]).toBeInTheDocument();
+ expect((await screen.findAllByText(title))[0]).toBeInTheDocument();
+ expect((await screen.findAllByText(title))[1]).toBeInTheDocument();
// Open by default; close the library info sidebar
const closeButton = screen.getByRole('button', { name: /close/i });
diff --git a/src/library-authoring/collections/LibraryCollectionPage.tsx b/src/library-authoring/collections/LibraryCollectionPage.tsx
index 443fe9fd48..efcf749999 100644
--- a/src/library-authoring/collections/LibraryCollectionPage.tsx
+++ b/src/library-authoring/collections/LibraryCollectionPage.tsx
@@ -13,8 +13,8 @@ import {
import { Add, InfoOutline } from '@openedx/paragon/icons';
import { Link, useParams } from 'react-router-dom';
-import { SearchParams } from 'meilisearch';
import Loading from '../../generic/Loading';
+import ErrorAlert from '../../generic/alert-error';
import SubHeader from '../../generic/sub-header/SubHeader';
import Header from '../../header';
import NotFoundAlert from '../../generic/NotFoundAlert';
@@ -25,9 +25,8 @@ import {
SearchContextProvider,
SearchKeywordsField,
SearchSortWidget,
- useSearchContext,
} from '../../search-manager';
-import { useContentLibrary } from '../data/apiHooks';
+import { useCollection, useContentLibrary } from '../data/apiHooks';
import { LibraryContext } from '../common/context';
import messages from './messages';
import { LibrarySidebar } from '../library-sidebar';
@@ -92,31 +91,48 @@ const SubHeaderTitle = ({
);
};
-const LibraryCollectionPageInner = ({ libraryId }: { libraryId: string }) => {
+const LibraryCollectionPage = () => {
const intl = useIntl();
+ const { libraryId, collectionId } = useParams();
+
+ if (!collectionId || !libraryId) {
+ // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
+ throw new Error('Rendered without collectionId or libraryId URL parameter');
+ }
+
const {
sidebarBodyComponent,
openCollectionInfoSidebar,
} = useContext(LibraryContext);
- const { collectionHits: [collectionData], isLoading } = useSearchContext();
+
+ const {
+ data: collectionData,
+ isLoading,
+ isError,
+ error,
+ } = useCollection(libraryId, collectionId);
useEffect(() => {
- openCollectionInfoSidebar();
- }, []);
+ openCollectionInfoSidebar(collectionId);
+ }, [collectionData]);
const { data: libraryData, isLoading: isLibLoading } = useContentLibrary(libraryId);
// Only show loading if collection data is not fetched from index yet
// Loading info for search results will be handled by LibraryCollectionComponents component.
- if (isLibLoading || (!collectionData && isLoading)) {
+ if (isLibLoading || isLoading) {
return ;
}
- if (!libraryData || !collectionData) {
+ if (!libraryData) {
return ;
}
+ if (isError) {
+ return ;
+ }
+
const breadcrumbs = [
{
label: libraryData.title,
@@ -144,65 +160,47 @@ const LibraryCollectionPageInner = ({ libraryId }: { libraryId: string }) => {
isLibrary
/>
-
- )}
- breadcrumbs={(
-
- )}
- headerActions={ }
- />
-
-
-
+
+ openCollectionInfoSidebar(collectionId)}
+ />
+ )}
+ breadcrumbs={(
+
+ )}
+ headerActions={ }
+ />
+
+
+
+
{ !!sidebarBodyComponent && (
-
+
)}
);
};
-const LibraryCollectionPage = () => {
- const { libraryId, collectionId } = useParams();
-
- if (!collectionId || !libraryId) {
- // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
- throw new Error('Rendered without collectionId or libraryId URL parameter');
- }
-
- const collectionQuery: SearchParams = {
- filter: ['type = "collection"', `context_key = "${libraryId}"`, `block_id = "${collectionId}"`],
- limit: 1,
- };
-
- return (
-
-
-
- );
-};
-
export default LibraryCollectionPage;
diff --git a/src/library-authoring/collections/messages.ts b/src/library-authoring/collections/messages.ts
index a4a491f387..5c5e0c03a0 100644
--- a/src/library-authoring/collections/messages.ts
+++ b/src/library-authoring/collections/messages.ts
@@ -1,6 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
+ openCollectionButton: {
+ id: 'course-authoring.library-authoring.collections-sidebbar.open-button',
+ defaultMessage: 'Open',
+ description: 'Button text to open collection',
+ },
manageTabTitle: {
id: 'course-authoring.library-authoring.collections-sidebar.manage-tab.title',
defaultMessage: 'Manage',
@@ -11,6 +16,41 @@ const messages = defineMessages({
defaultMessage: 'Details',
description: 'Title for details tab',
},
+ detailsTabDescriptionTitle: {
+ id: 'course-authoring.library-authoring.collections-sidebar.details-tab.description-title',
+ defaultMessage: 'Description / Card Preview Text',
+ description: 'Title for the Description container in the details tab',
+ },
+ detailsTabDescriptionPlaceholder: {
+ id: 'course-authoring.library-authoring.collections-sidebar.details-tab.description-placeholder',
+ defaultMessage: 'Add description',
+ description: 'Placeholder for the Description container in the details tab',
+ },
+ detailsTabStatsTitle: {
+ id: 'course-authoring.library-authoring.collections-sidebar.details-tab.stats-title',
+ defaultMessage: 'Collection Stats',
+ description: 'Title for the Collection Stats container in the details tab',
+ },
+ detailsTabStatsNoComponents: {
+ id: 'course-authoring.library-authoring.collections-sidebar.details-tab.stats-no-components',
+ defaultMessage: 'This collection is currently empty.',
+ description: 'Message displayed when no components are found in the Collection Stats container',
+ },
+ detailsTabStatsTotalComponents: {
+ id: 'course-authoring.library-authoring.collections-sidebar.details-tab.stats-total-components',
+ defaultMessage: 'Total ',
+ description: 'Label for total components in the Collection Stats container',
+ },
+ detailsTabStatsOtherComponents: {
+ id: 'course-authoring.library-authoring.collections-sidebar.details-tab.stats-other-components',
+ defaultMessage: 'Other',
+ description: 'Label for other components in the Collection Stats container',
+ },
+ detailsTabHistoryTitle: {
+ id: 'course-authoring.library-authoring.collections-sidebar.details-tab.history-title',
+ defaultMessage: 'Collection History',
+ description: 'Title for the Collection History container in the details tab',
+ },
noComponentsInCollection: {
id: 'course-authoring.library-authoring.collections-pag.no-components.text',
defaultMessage: 'This collection is currently empty.',
@@ -71,6 +111,21 @@ const messages = defineMessages({
defaultMessage: 'Add collection',
description: 'Button text to add a new collection',
},
+ updateCollectionSuccessMsg: {
+ id: 'course-authoring.library-authoring.update-collection-success-msg',
+ defaultMessage: 'Collection updated successfully.',
+ description: 'Message displayed when collection is updated successfully',
+ },
+ updateCollectionErrorMsg: {
+ id: 'course-authoring.library-authoring.update-collection-error-msg',
+ defaultMessage: 'Failed to update collection.',
+ description: 'Message displayed when collection update fails',
+ },
+ editTitleButtonAlt: {
+ id: 'course-authoring.library-authoring.collection.sidebar.edit-name.alt',
+ defaultMessage: 'Edit collection title',
+ description: 'Alt text for edit collection title icon button',
+ },
});
export default messages;
diff --git a/src/library-authoring/common/context.tsx b/src/library-authoring/common/context.tsx
index cd82a2d84a..86b862e96a 100644
--- a/src/library-authoring/common/context.tsx
+++ b/src/library-authoring/common/context.tsx
@@ -18,7 +18,8 @@ export interface LibraryContextData {
isCreateCollectionModalOpen: boolean;
openCreateCollectionModal: () => void;
closeCreateCollectionModal: () => void;
- openCollectionInfoSidebar: () => void;
+ openCollectionInfoSidebar: (collectionId: string) => void;
+ currentCollectionId?: string;
}
export const LibraryContext = React.createContext({
@@ -30,7 +31,8 @@ export const LibraryContext = React.createContext({
isCreateCollectionModalOpen: false,
openCreateCollectionModal: () => {},
closeCreateCollectionModal: () => {},
- openCollectionInfoSidebar: () => {},
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ openCollectionInfoSidebar: (_collectionId: string) => {},
} as LibraryContextData);
/**
@@ -39,29 +41,38 @@ export const LibraryContext = React.createContext({
export const LibraryProvider = (props: { children?: React.ReactNode }) => {
const [sidebarBodyComponent, setSidebarBodyComponent] = React.useState(null);
const [currentComponentUsageKey, setCurrentComponentUsageKey] = React.useState();
+ const [currentCollectionId, setcurrentCollectionId] = React.useState();
const [isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal] = useToggle(false);
- const closeLibrarySidebar = React.useCallback(() => {
+ const resetSidebar = React.useCallback(() => {
+ setCurrentComponentUsageKey(undefined);
+ setcurrentCollectionId(undefined);
setSidebarBodyComponent(null);
+ }, []);
+
+ const closeLibrarySidebar = React.useCallback(() => {
+ resetSidebar();
setCurrentComponentUsageKey(undefined);
}, []);
const openAddContentSidebar = React.useCallback(() => {
- setCurrentComponentUsageKey(undefined);
+ resetSidebar();
setSidebarBodyComponent(SidebarBodyComponentId.AddContent);
}, []);
const openInfoSidebar = React.useCallback(() => {
- setCurrentComponentUsageKey(undefined);
+ resetSidebar();
setSidebarBodyComponent(SidebarBodyComponentId.Info);
}, []);
const openComponentInfoSidebar = React.useCallback(
(usageKey: string) => {
+ resetSidebar();
setCurrentComponentUsageKey(usageKey);
setSidebarBodyComponent(SidebarBodyComponentId.ComponentInfo);
},
[],
);
- const openCollectionInfoSidebar = React.useCallback(() => {
- setCurrentComponentUsageKey(undefined);
+ const openCollectionInfoSidebar = React.useCallback((collectionId: string) => {
+ resetSidebar();
+ setcurrentCollectionId(collectionId);
setSidebarBodyComponent(SidebarBodyComponentId.CollectionInfo);
}, []);
@@ -76,6 +87,7 @@ export const LibraryProvider = (props: { children?: React.ReactNode }) => {
openCreateCollectionModal,
closeCreateCollectionModal,
openCollectionInfoSidebar,
+ currentCollectionId,
}), [
sidebarBodyComponent,
closeLibrarySidebar,
@@ -87,6 +99,7 @@ export const LibraryProvider = (props: { children?: React.ReactNode }) => {
openCreateCollectionModal,
closeCreateCollectionModal,
openCollectionInfoSidebar,
+ currentCollectionId,
]);
return (
diff --git a/src/library-authoring/component-info/ComponentManagement.test.tsx b/src/library-authoring/component-info/ComponentManagement.test.tsx
index 314c36de2a..96a12ec5dd 100644
--- a/src/library-authoring/component-info/ComponentManagement.test.tsx
+++ b/src/library-authoring/component-info/ComponentManagement.test.tsx
@@ -7,6 +7,11 @@ import {
} from '../../testUtils';
import { mockLibraryBlockMetadata } from '../data/api.mocks';
import ComponentManagement from './ComponentManagement';
+import { mockContentTaxonomyTagsData } from '../../content-tags-drawer/data/api.mocks';
+
+jest.mock('../../content-tags-drawer', () => ({
+ ContentTagsDrawer: () => Mocked ContentTagsDrawer
,
+}));
/*
* This function is used to get the inner text of an element.
@@ -51,9 +56,8 @@ describe(' ', () => {
initializeMocks();
mockLibraryBlockMetadata.applyMock();
render( );
- expect(await screen.findByText('Tags')).toBeInTheDocument();
- // TODO: replace with actual data when implement tag list
- expect(screen.queryByText('Tags placeholder')).toBeInTheDocument();
+ expect(await screen.findByText('Tags (0)')).toBeInTheDocument();
+ expect(screen.queryByText('Mocked ContentTagsDrawer')).toBeInTheDocument();
});
it('should not render draft status', async () => {
@@ -67,4 +71,16 @@ describe(' ', () => {
expect(await screen.findByText('Draft')).toBeInTheDocument();
expect(screen.queryByText('Tags')).not.toBeInTheDocument();
});
+
+ it('should render tag count in tagging info', async () => {
+ setConfig({
+ ...getConfig(),
+ ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
+ });
+ initializeMocks();
+ mockLibraryBlockMetadata.applyMock();
+ mockContentTaxonomyTagsData.applyMock();
+ render( );
+ expect(await screen.findByText('Tags (6)')).toBeInTheDocument();
+ });
});
diff --git a/src/library-authoring/component-info/ComponentManagement.tsx b/src/library-authoring/component-info/ComponentManagement.tsx
index 12a9cea75c..92adb33107 100644
--- a/src/library-authoring/component-info/ComponentManagement.tsx
+++ b/src/library-authoring/component-info/ComponentManagement.tsx
@@ -1,3 +1,4 @@
+import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Collapsible, Icon, Stack } from '@openedx/paragon';
@@ -6,6 +7,8 @@ import { Tag } from '@openedx/paragon/icons';
import { useLibraryBlockMetadata } from '../data/apiHooks';
import StatusWidget from '../generic/status-widget';
import messages from './messages';
+import { ContentTagsDrawer } from '../../content-tags-drawer';
+import { useContentTaxonomyTagsData } from '../../content-tags-drawer/data/apiHooks';
interface ComponentManagementProps {
usageKey: string;
@@ -13,6 +16,26 @@ interface ComponentManagementProps {
const ComponentManagement = ({ usageKey }: ComponentManagementProps) => {
const intl = useIntl();
const { data: componentMetadata } = useLibraryBlockMetadata(usageKey);
+ const { data: componentTags } = useContentTaxonomyTagsData(usageKey);
+
+ const tagsCount = React.useMemo(() => {
+ if (!componentTags) {
+ return 0;
+ }
+ let result = 0;
+ componentTags.taxonomies.forEach((taxonomy) => {
+ const countedTags : string[] = [];
+ taxonomy.tags.forEach((tagData) => {
+ tagData.lineage.forEach((tag) => {
+ if (!countedTags.includes(tag)) {
+ result += 1;
+ countedTags.push(tag);
+ }
+ });
+ });
+ });
+ return result;
+ }, [componentTags]);
// istanbul ignore if: this should never happen
if (!componentMetadata) {
@@ -31,12 +54,15 @@ const ComponentManagement = ({ usageKey }: ComponentManagementProps) => {
title={(
- {intl.formatMessage(messages.manageTabTagsTitle)}
+ {intl.formatMessage(messages.manageTabTagsTitle, { count: tagsCount })}
)}
className="border-0"
>
- Tags placeholder
+
)}
diff --git a/src/library-authoring/component-info/messages.ts b/src/library-authoring/component-info/messages.ts
index c24146160e..2847ab9137 100644
--- a/src/library-authoring/component-info/messages.ts
+++ b/src/library-authoring/component-info/messages.ts
@@ -38,7 +38,7 @@ const messages = defineMessages({
},
manageTabTagsTitle: {
id: 'course-authoring.library-authoring.component.manage-tab.tags-title',
- defaultMessage: 'Tags',
+ defaultMessage: 'Tags ({count})',
description: 'Title for the Tags container in the management tab',
},
manageTabCollectionsTitle: {
diff --git a/src/library-authoring/components/CollectionCard.test.tsx b/src/library-authoring/components/CollectionCard.test.tsx
index a3be58151f..92f2c8d981 100644
--- a/src/library-authoring/components/CollectionCard.test.tsx
+++ b/src/library-authoring/components/CollectionCard.test.tsx
@@ -1,4 +1,9 @@
-import { initializeMocks, render, screen } from '../../testUtils';
+import {
+ initializeMocks,
+ fireEvent,
+ render,
+ screen,
+} from '../../testUtils';
import { type CollectionHit } from '../../search-manager';
import CollectionCard from './CollectionCard';
@@ -7,6 +12,8 @@ const CollectionHitSample: CollectionHit = {
id: '1',
type: 'collection',
contextKey: 'lb:org1:Demo_Course',
+ usageKey: 'lb:org1:Demo_Course:collection1',
+ blockId: 'collection1',
org: 'org1',
breadcrumbs: [{ displayName: 'Demo Lib' }],
displayName: 'Collection Display Name',
@@ -37,4 +44,18 @@ describe(' ', () => {
expect(screen.queryByText('Collection description')).toBeInTheDocument();
expect(screen.queryByText('Collection (2)')).toBeInTheDocument();
});
+
+ it('should navigate to the collection if the open menu clicked', async () => {
+ render( );
+
+ // Open menu
+ expect(screen.getByTestId('collection-card-menu-toggle')).toBeInTheDocument();
+ fireEvent.click(screen.getByTestId('collection-card-menu-toggle'));
+
+ // Open menu item
+ const openMenuItem = screen.getByRole('link', { name: 'Open' });
+ expect(openMenuItem).toBeInTheDocument();
+
+ expect(openMenuItem).toHaveAttribute('href', '/library/lb:org1:Demo_Course/collection/collection1/');
+ });
});
diff --git a/src/library-authoring/components/CollectionCard.tsx b/src/library-authoring/components/CollectionCard.tsx
index 3968a7d681..c8114ec7e7 100644
--- a/src/library-authoring/components/CollectionCard.tsx
+++ b/src/library-authoring/components/CollectionCard.tsx
@@ -1,21 +1,54 @@
-import { useIntl } from '@edx/frontend-platform/i18n';
+import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import {
ActionRow,
+ Dropdown,
Icon,
IconButton,
} from '@openedx/paragon';
import { MoreVert } from '@openedx/paragon/icons';
+import { useContext } from 'react';
+import { Link } from 'react-router-dom';
import { type CollectionHit } from '../../search-manager';
-import messages from './messages';
+import { LibraryContext } from '../common/context';
import BaseComponentCard from './BaseComponentCard';
+import messages from './messages';
+
+export const CollectionMenu = ({ collectionHit }: { collectionHit: CollectionHit }) => {
+ const intl = useIntl();
+
+ return (
+ e.stopPropagation()}>
+
+
+
+
+
+
+
+ );
+};
type CollectionCardProps = {
collectionHit: CollectionHit,
};
-const CollectionCard = ({ collectionHit } : CollectionCardProps) => {
+const CollectionCard = ({ collectionHit }: CollectionCardProps) => {
const intl = useIntl();
+ const {
+ openCollectionInfoSidebar,
+ } = useContext(LibraryContext);
const {
type,
@@ -37,16 +70,11 @@ const CollectionCard = ({ collectionHit } : CollectionCardProps) => {
tags={tags}
actions={(
-
+
)}
blockTypeDisplayName={blockTypeDisplayName}
- openInfoSidebar={() => {}}
+ openInfoSidebar={() => openCollectionInfoSidebar(collectionHit.blockId)}
/>
);
};
diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts
index e801f7ec0b..c826bae09d 100644
--- a/src/library-authoring/components/messages.ts
+++ b/src/library-authoring/components/messages.ts
@@ -21,6 +21,11 @@ const messages = defineMessages({
defaultMessage: 'Collection ({numChildren})',
description: 'Collection type text with children count',
},
+ menuOpen: {
+ id: 'course-authoring.library-authoring.collection.menu.open',
+ defaultMessage: 'Open',
+ description: 'Menu item for open a collection.',
+ },
menuEdit: {
id: 'course-authoring.library-authoring.component.menu.edit',
defaultMessage: 'Edit',
diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts
index 505a9d1d16..0a4b824119 100644
--- a/src/library-authoring/data/api.mocks.ts
+++ b/src/library-authoring/data/api.mocks.ts
@@ -1,4 +1,5 @@
/* istanbul ignore file */
+import { mockContentTaxonomyTagsData } from '../../content-tags-drawer/data/api.mocks';
import { createAxiosError } from '../../testUtils';
import * as api from './api';
@@ -234,6 +235,7 @@ export async function mockLibraryBlockMetadata(usageKey: string): Promise jest.spyOn(api, 'getLibraryBlockMetadata').mockImplementation(mockLibraryBlockMetadata);
+
+/**
+ * Mock for `getCollectionMetadata()`
+ *
+ * This mock returns a fixed response for the collection ID *collection_1*.
+ */
+export async function mockGetCollectionMetadata(libraryId: string, collectionId: string): Promise {
+ if (collectionId === mockGetCollectionMetadata.collectionIdError) {
+ throw createAxiosError({ code: 400, message: 'Not found.', path: api.getLibraryCollectionApiUrl(libraryId, collectionId) });
+ }
+ return Promise.resolve(mockGetCollectionMetadata.collectionData);
+}
+mockGetCollectionMetadata.collectionId = 'collection_1';
+mockGetCollectionMetadata.collectionIdError = 'collection_error';
+mockGetCollectionMetadata.collectionData = {
+ id: 1,
+ key: 'collection_1',
+ title: 'Test Collection',
+ description: 'A collection for testing',
+ created: '2024-09-19T10:00:00Z',
+ createdBy: 'test_author',
+ modified: '2024-09-20T11:00:00Z',
+ learningPackage: 11,
+ enabled: true,
+} satisfies api.Collection;
+/** Apply this mock. Returns a spy object that can tell you if it's been called. */
+mockGetCollectionMetadata.applyMock = () => {
+ jest.spyOn(api, 'getCollectionMetadata').mockImplementation(mockGetCollectionMetadata);
+};
diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts
index 970a79a96e..5c96176763 100644
--- a/src/library-authoring/data/api.ts
+++ b/src/library-authoring/data/api.ts
@@ -180,6 +180,8 @@ export interface CreateLibraryCollectionDataRequest {
description: string | null;
}
+export type UpdateCollectionComponentsRequest = Partial;
+
/**
* Fetch the list of XBlock types that can be added to this library
*/
@@ -316,10 +318,30 @@ export async function getXBlockOLX(usageKey: string): Promise {
return data.olx;
}
+/**
+ * Get the collection metadata.
+ */
+export async function getCollectionMetadata(libraryId: string, collectionId: string): Promise {
+ const { data } = await getAuthenticatedHttpClient().get(getLibraryCollectionApiUrl(libraryId, collectionId));
+ return camelCaseObject(data);
+}
+
+/**
+ * Update collection metadata.
+ */
+export async function updateCollectionMetadata(
+ libraryId: string,
+ collectionId: string,
+ collectionData: UpdateCollectionComponentsRequest,
+) {
+ const client = getAuthenticatedHttpClient();
+ await client.patch(getLibraryCollectionApiUrl(libraryId, collectionId), collectionData);
+}
+
/**
* Update collection components.
*/
-export async function updateCollectionComponents(libraryId:string, collectionId: string, usageKeys: string[]) {
+export async function updateCollectionComponents(libraryId: string, collectionId: string, usageKeys: string[]) {
await getAuthenticatedHttpClient().patch(getLibraryCollectionComponentApiUrl(libraryId, collectionId), {
usage_keys: usageKeys,
});
diff --git a/src/library-authoring/data/apiHooks.test.tsx b/src/library-authoring/data/apiHooks.test.tsx
index 70ba691635..c21f5f5a67 100644
--- a/src/library-authoring/data/apiHooks.test.tsx
+++ b/src/library-authoring/data/apiHooks.test.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import { waitFor } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import MockAdapter from 'axios-mock-adapter';
@@ -10,6 +11,7 @@ import {
getCreateLibraryBlockUrl,
getLibraryCollectionComponentApiUrl,
getLibraryCollectionsApiUrl,
+ getLibraryCollectionApiUrl,
} from './api';
import {
useCommitLibraryChanges,
@@ -17,6 +19,7 @@ import {
useCreateLibraryCollection,
useRevertLibraryChanges,
useUpdateCollectionComponents,
+ useCollection,
} from './apiHooks';
let axiosMock;
@@ -106,4 +109,18 @@ describe('library api hooks', () => {
expect(axiosMock.history.patch[0].url).toEqual(url);
});
+
+ it('should get collection metadata', async () => {
+ const libraryId = 'lib:org:1';
+ const collectionId = 'my-first-collection';
+ const url = getLibraryCollectionApiUrl(libraryId, collectionId);
+
+ axiosMock.onGet(url).reply(200, { 'test-data': 'test-value' });
+ const { result } = renderHook(() => useCollection(libraryId, collectionId), { wrapper });
+ await waitFor(() => {
+ expect(result.current.isLoading).toBeFalsy();
+ });
+ expect(result.current.data).toEqual({ testData: 'test-value' });
+ expect(axiosMock.history.get[0].url).toEqual(url);
+ });
});
diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts
index 96b7122af8..40601356b4 100644
--- a/src/library-authoring/data/apiHooks.ts
+++ b/src/library-authoring/data/apiHooks.ts
@@ -26,11 +26,14 @@ import {
updateXBlockFields,
createCollection,
getXBlockOLX,
+ updateCollectionMetadata,
+ type UpdateCollectionComponentsRequest,
updateCollectionComponents,
type CreateLibraryCollectionDataRequest,
+ getCollectionMetadata,
} from './api';
-const libraryQueryPredicate = (query: Query, libraryId: string): boolean => {
+export const libraryQueryPredicate = (query: Query, libraryId: string): boolean => {
// Invalidate all content queries related to this library.
// If we allow searching "all courses and libraries" in the future,
// then we'd have to invalidate all `["content_search", "results"]`
@@ -278,6 +281,33 @@ export const useXBlockOLX = (usageKey: string) => (
})
);
+/**
+ * Get the metadata for a collection in a library
+ */
+export const useCollection = (libraryId: string, collectionId: string) => (
+ useQuery({
+ enabled: !!libraryId && !!collectionId,
+ queryKey: libraryAuthoringQueryKeys.collection(libraryId, collectionId),
+ queryFn: () => getCollectionMetadata(libraryId!, collectionId!),
+ })
+);
+
+/**
+ * Use this mutation to update the fields of a collection in a library
+ */
+export const useUpdateCollection = (libraryId: string, collectionId: string) => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (data: UpdateCollectionComponentsRequest) => updateCollectionMetadata(libraryId, collectionId, data),
+ onSettled: () => {
+ // NOTE: We invalidate the library query here because we need to update the library's
+ // collection list.
+ queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
+ queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.collection(libraryId, collectionId) });
+ },
+ });
+};
+
/**
* Use this mutation to add components to a collection in a library
*/
diff --git a/src/library-authoring/generic/history-widget/index.tsx b/src/library-authoring/generic/history-widget/index.tsx
index 615a570504..8b49d03d77 100644
--- a/src/library-authoring/generic/history-widget/index.tsx
+++ b/src/library-authoring/generic/history-widget/index.tsx
@@ -3,7 +3,7 @@ import { Stack } from '@openedx/paragon';
import messages from './messages';
-const CustomFormattedDate = ({ date }: { date: string }) => (
+const CustomFormattedDate = ({ date }: { date: string | Date }) => (
(
);
type HistoryWidgedProps = {
- modified: string | null;
- created: string | null;
+ modified: string | Date | null;
+ created: string | Date | null;
};
/**
diff --git a/src/library-authoring/generic/index.scss b/src/library-authoring/generic/index.scss
index b7c9c75447..aa5bf5cc50 100644
--- a/src/library-authoring/generic/index.scss
+++ b/src/library-authoring/generic/index.scss
@@ -1,2 +1,2 @@
-@import "./status-widget/StatusWidget";
@import "./history-widget/HistoryWidget";
+@import "./status-widget/StatusWidget";
diff --git a/src/library-authoring/library-sidebar/LibrarySidebar.tsx b/src/library-authoring/library-sidebar/LibrarySidebar.tsx
index d1ac43de22..a7ce2b5b5b 100644
--- a/src/library-authoring/library-sidebar/LibrarySidebar.tsx
+++ b/src/library-authoring/library-sidebar/LibrarySidebar.tsx
@@ -6,18 +6,17 @@ import {
} from '@openedx/paragon';
import { Close } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
-import messages from '../messages';
+
import { AddContentContainer, AddContentHeader } from '../add-content';
+import { CollectionInfo, CollectionInfoHeader } from '../collections';
+import { ContentLibrary } from '../data/api';
import { LibraryContext, SidebarBodyComponentId } from '../common/context';
-import { LibraryInfo, LibraryInfoHeader } from '../library-info';
import { ComponentInfo, ComponentInfoHeader } from '../component-info';
-import { ContentLibrary } from '../data/api';
-import { CollectionInfo, CollectionInfoHeader } from '../collections';
-import { type CollectionHit } from '../../search-manager/data/api';
+import { LibraryInfo, LibraryInfoHeader } from '../library-info';
+import messages from '../messages';
type LibrarySidebarProps = {
library: ContentLibrary,
- collection?: CollectionHit,
};
/**
@@ -29,12 +28,13 @@ type LibrarySidebarProps = {
* You can add more components in `bodyComponentMap`.
* Use the returned actions to open and close this sidebar.
*/
-const LibrarySidebar = ({ library, collection }: LibrarySidebarProps) => {
+const LibrarySidebar = ({ library }: LibrarySidebarProps) => {
const intl = useIntl();
const {
sidebarBodyComponent,
closeLibrarySidebar,
currentComponentUsageKey,
+ currentCollectionId,
} = useContext(LibraryContext);
const bodyComponentMap = {
@@ -43,7 +43,9 @@ const LibrarySidebar = ({ library, collection }: LibrarySidebarProps) => {
[SidebarBodyComponentId.ComponentInfo]: (
currentComponentUsageKey &&
),
- [SidebarBodyComponentId.CollectionInfo]: ,
+ [SidebarBodyComponentId.CollectionInfo]: (
+ currentCollectionId &&
+ ),
unknown: null,
};
@@ -53,7 +55,9 @@ const LibrarySidebar = ({ library, collection }: LibrarySidebarProps) => {
[SidebarBodyComponentId.ComponentInfo]: (
currentComponentUsageKey &&
),
- [SidebarBodyComponentId.CollectionInfo]: ,
+ [SidebarBodyComponentId.CollectionInfo]: (
+ currentCollectionId &&
+ ),
unknown: null,
};
diff --git a/src/pages-and-resources/data/api.js b/src/pages-and-resources/data/api.js
index ec865f4fd2..92ddc751b2 100644
--- a/src/pages-and-resources/data/api.js
+++ b/src/pages-and-resources/data/api.js
@@ -1,4 +1,3 @@
-/* eslint-disable import/prefer-default-export */
import { snakeCase } from 'lodash/string';
import { camelCaseObject, ensureConfig, getConfig } from '@edx/frontend-platform';
@@ -9,8 +8,8 @@ ensureConfig([
], 'Course Apps API service');
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
-const getCourseAppsApiUrl = () => `${getApiBaseUrl()}/api/course_apps/v1/apps`;
-const getCourseAdvancedSettingsApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v0/advanced_settings`;
+export const getCourseAppsApiUrl = () => `${getApiBaseUrl()}/api/course_apps/v1/apps`;
+export const getCourseAdvancedSettingsApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v0/advanced_settings`;
/**
* Fetches the course apps installed for provided course
diff --git a/src/pages-and-resources/data/thunks.js b/src/pages-and-resources/data/thunks.js
index 06b45459d7..f6384f6d7c 100644
--- a/src/pages-and-resources/data/thunks.js
+++ b/src/pages-and-resources/data/thunks.js
@@ -78,7 +78,11 @@ export function fetchCourseAppSettings(courseId, settings) {
dispatch(fetchCourseAppsSettingsSuccess(settingValues));
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
- dispatch(updateLoadingStatus({ status: RequestStatus.FAILED }));
+ if (error.response && error.response.status === 403) {
+ dispatch(updateLoadingStatus({ status: RequestStatus.DENIED }));
+ } else {
+ dispatch(updateLoadingStatus({ status: RequestStatus.FAILED }));
+ }
}
};
}
diff --git a/src/search-manager/SearchManager.ts b/src/search-manager/SearchManager.ts
index eb457a10e2..1726e10d9f 100644
--- a/src/search-manager/SearchManager.ts
+++ b/src/search-manager/SearchManager.ts
@@ -166,7 +166,7 @@ export const SearchContextProvider: React.FC<{
searchSortOrder,
setSearchSortOrder,
defaultSearchSortOrder,
- closeSearchModal: props.closeSearchModal ?? (() => {}),
+ closeSearchModal: props.closeSearchModal ?? (() => { }),
hasError: hasConnectionError || result.isError,
...result,
},
diff --git a/src/search-manager/data/__mocks__/block-types.json b/src/search-manager/data/__mocks__/block-types.json
new file mode 100644
index 0000000000..9d812df91b
--- /dev/null
+++ b/src/search-manager/data/__mocks__/block-types.json
@@ -0,0 +1,24 @@
+{
+ "comment": "This is a mock of the response from Meilisearch, based on an actual search in Studio.",
+ "results": [
+ {
+ "indexUid": "studio",
+ "hits": [],
+ "query": "",
+ "processingTimeMs": 1,
+ "limit": 0,
+ "offset": 0,
+ "estimatedTotalHits": 0,
+ "facetDistribution": {
+ "block_type": {
+ "chapter": 1,
+ "html": 2,
+ "problem": 16,
+ "vertical": 2,
+ "video": 1
+ }
+ },
+ "facetStats": {}
+ }
+ ]
+}
diff --git a/src/search-manager/data/api.mock.ts b/src/search-manager/data/api.mock.ts
index dfcc9584ae..bfed5a3694 100644
--- a/src/search-manager/data/api.mock.ts
+++ b/src/search-manager/data/api.mock.ts
@@ -40,3 +40,27 @@ export function mockSearchResult(mockResponse: MultiSearchResponse) {
return newMockResponse;
}, { overwriteRoutes: true });
}
+
+/**
+ * Mock the block types returned by the API.
+ */
+export async function mockGetBlockTypes(
+ mockResponse: 'noBlocks' | 'someBlocks' | 'moreBlocks',
+) {
+ const mockResponseMap = {
+ noBlocks: {},
+ someBlocks: { problem: 1, html: 2 },
+ moreBlocks: {
+ advanced: 1,
+ discussion: 2,
+ library: 3,
+ drag_and_drop_v2: 4,
+ openassessment: 5,
+ html: 6,
+ problem: 7,
+ video: 8,
+ },
+ };
+ jest.spyOn(api, 'fetchBlockTypes').mockResolvedValue(mockResponseMap[mockResponse]);
+}
+mockGetBlockTypes.applyMock = () => jest.spyOn(api, 'fetchBlockTypes').mockResolvedValue({});
diff --git a/src/search-manager/data/api.ts b/src/search-manager/data/api.ts
index d5d524a81e..5feeb2456a 100644
--- a/src/search-manager/data/api.ts
+++ b/src/search-manager/data/api.ts
@@ -101,6 +101,8 @@ interface BaseContentHit {
id: string;
type: 'course_block' | 'library_block' | 'collection';
displayName: string;
+ usageKey: string;
+ blockId: string;
/** The course or library ID */
contextKey: string;
org: string;
@@ -117,8 +119,6 @@ interface BaseContentHit {
* Defined in edx-platform/openedx/core/djangoapps/content/search/documents.py
*/
export interface ContentHit extends BaseContentHit {
- usageKey: string;
- blockId: string;
/** The block_type part of the usage key. What type of XBlock this is. */
blockType: string;
/**
@@ -144,7 +144,7 @@ export interface CollectionHit extends BaseContentHit {
* Convert search hits to camelCase
* @param hit A search result directly from Meilisearch
*/
-function formatSearchHit(hit: Record): ContentHit | CollectionHit {
+export function formatSearchHit(hit: Record): ContentHit | CollectionHit {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { _formatted, ...newHit } = hit;
newHit.formatted = {
@@ -303,6 +303,29 @@ export async function fetchSearchResults({
};
}
+/**
+ * Fetch the block types facet distribution for the search results.
+ */
+export const fetchBlockTypes = async (
+ client: MeiliSearch,
+ indexName: string,
+ extraFilter?: Filter,
+): Promise> => {
+ // Convert 'extraFilter' into an array
+ const extraFilterFormatted = forceArray(extraFilter);
+
+ const { results } = await client.multiSearch({
+ queries: [{
+ indexUid: indexName,
+ facets: ['block_type'],
+ filter: extraFilterFormatted,
+ limit: 0, // We don't need any "hits" for this - just the facetDistribution
+ }],
+ });
+
+ return results[0].facetDistribution?.block_type ?? {};
+};
+
/** Information about a single tag in the tag tree, as returned by fetchAvailableTagOptions() */
export interface TagEntry {
tagName: string;
diff --git a/src/search-manager/data/apiHooks.test.tsx b/src/search-manager/data/apiHooks.test.tsx
new file mode 100644
index 0000000000..b6fc63f49a
--- /dev/null
+++ b/src/search-manager/data/apiHooks.test.tsx
@@ -0,0 +1,57 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { waitFor } from '@testing-library/react';
+import { renderHook } from '@testing-library/react-hooks';
+import fetchMock from 'fetch-mock-jest';
+
+import mockResult from './__mocks__/block-types.json';
+import { mockContentSearchConfig } from './api.mock';
+import {
+ useGetBlockTypes,
+} from './apiHooks';
+
+mockContentSearchConfig.applyMock();
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+});
+
+const wrapper = ({ children }) => (
+
+ {children}
+
+);
+
+const fetchMockResponse = () => {
+ fetchMock.post(
+ mockContentSearchConfig.searchEndpointUrl,
+ () => mockResult,
+ { overwriteRoutes: true },
+ );
+};
+
+describe('search manager api hooks', () => {
+ afterEach(() => {
+ fetchMock.reset();
+ });
+
+ it('it should return block types facet', async () => {
+ fetchMockResponse();
+ const { result } = renderHook(() => useGetBlockTypes('filter'), { wrapper });
+ await waitFor(() => {
+ expect(result.current.isLoading).toBeFalsy();
+ });
+ const expectedData = {
+ chapter: 1,
+ html: 2,
+ problem: 16,
+ vertical: 2,
+ video: 1,
+ };
+ expect(result.current.data).toEqual(expectedData);
+ expect(fetchMock.calls().length).toEqual(1);
+ });
+});
diff --git a/src/search-manager/data/apiHooks.ts b/src/search-manager/data/apiHooks.ts
index 02b1c679a4..cd63bbb344 100644
--- a/src/search-manager/data/apiHooks.ts
+++ b/src/search-manager/data/apiHooks.ts
@@ -10,6 +10,7 @@ import {
fetchTagsThatMatchKeyword,
getContentSearchConfig,
fetchDocumentById,
+ fetchBlockTypes,
OverrideQueries,
} from './api';
@@ -243,6 +244,22 @@ export const useTagFilterOptions = (args: {
return { ...mainQuery, data };
};
+export const useGetBlockTypes = (extraFilters: Filter) => {
+ const { client, indexName } = useContentSearchConnection();
+ return useQuery({
+ enabled: client !== undefined && indexName !== undefined,
+ queryKey: [
+ 'content_search',
+ client?.config.apiKey,
+ client?.config.host,
+ indexName,
+ extraFilters,
+ 'block_types',
+ ],
+ queryFn: () => fetchBlockTypes(client!, indexName!, extraFilters),
+ });
+};
+
/* istanbul ignore next */
export const useGetSingleDocument = ({ client, indexName, id }: {
client?: MeiliSearch;
diff --git a/src/search-manager/index.ts b/src/search-manager/index.ts
index 0f716a9c3e..e2d4188be1 100644
--- a/src/search-manager/index.ts
+++ b/src/search-manager/index.ts
@@ -1,4 +1,5 @@
export { SearchContextProvider, useSearchContext } from './SearchManager';
+export { default as BlockTypeLabel } from './BlockTypeLabel';
export { default as ClearFiltersButton } from './ClearFiltersButton';
export { default as FilterByBlockType } from './FilterByBlockType';
export { default as FilterByTags } from './FilterByTags';
@@ -7,5 +8,6 @@ export { default as SearchKeywordsField } from './SearchKeywordsField';
export { default as SearchSortWidget } from './SearchSortWidget';
export { default as Stats } from './Stats';
export { HIGHLIGHT_PRE_TAG, HIGHLIGHT_POST_TAG } from './data/api';
+export { useGetBlockTypes } from './data/apiHooks';
export type { CollectionHit, ContentHit, ContentHitTags } from './data/api';
diff --git a/src/search-modal/SearchResult.tsx b/src/search-modal/SearchResult.tsx
index 9ba6ee9516..032d5cda1a 100644
--- a/src/search-modal/SearchResult.tsx
+++ b/src/search-modal/SearchResult.tsx
@@ -7,14 +7,11 @@ import {
Stack,
} from '@openedx/paragon';
import { OpenInNew } from '@openedx/paragon/icons';
-import { useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { getItemIcon } from '../generic/block-type-utils';
import { isLibraryKey } from '../generic/key-utils';
import { useSearchContext, type ContentHit, Highlight } from '../search-manager';
-import { getStudioHomeData } from '../studio-home/data/selectors';
-import { constructLibraryAuthoringURL } from '../utils';
import messages from './messages';
/**
@@ -100,7 +97,6 @@ const SearchResult: React.FC<{ hit: ContentHit }> = ({ hit }) => {
const intl = useIntl();
const navigate = useNavigate();
const { closeSearchModal } = useSearchContext();
- const { libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe } = useSelector(getStudioHomeData);
/**
* Returns the URL for the context of the hit
@@ -119,10 +115,6 @@ const SearchResult: React.FC<{ hit: ContentHit }> = ({ hit }) => {
if (isLibraryKey(contextKey)) {
const urlSuffix = getLibraryComponentUrlSuffix(hit);
- if (redirectToLibraryAuthoringMfe && libraryAuthoringMfeUrl) {
- return constructLibraryAuthoringURL(libraryAuthoringMfeUrl, urlSuffix);
- }
-
if (newWindow) {
return `${getPath(getConfig().PUBLIC_PATH)}${urlSuffix}`;
}
@@ -131,7 +123,7 @@ const SearchResult: React.FC<{ hit: ContentHit }> = ({ hit }) => {
// istanbul ignore next - This case should never be reached
return undefined;
- }, [libraryAuthoringMfeUrl, redirectToLibraryAuthoringMfe, hit]);
+ }, [hit]);
/**
* Opens the context of the hit in a new window
diff --git a/src/search-modal/SearchUI.test.tsx b/src/search-modal/SearchUI.test.tsx
index acb3b5efe3..5ed9b802d7 100644
--- a/src/search-modal/SearchUI.test.tsx
+++ b/src/search-modal/SearchUI.test.tsx
@@ -16,10 +16,6 @@ import fetchMock from 'fetch-mock-jest';
import type { Store } from 'redux';
import initializeStore from '../store';
-import { executeThunk } from '../utils';
-import { getStudioHomeApiUrl } from '../studio-home/data/api';
-import { fetchStudioHomeData } from '../studio-home/data/thunks';
-import { generateGetStudioHomeDataApiResponse } from '../studio-home/factories/mockApiResponses';
import mockResult from './__mocks__/search-result.json';
import mockEmptyResult from './__mocks__/empty-search-result.json';
import mockTagsFacetResult from './__mocks__/facet-search.json';
@@ -316,43 +312,7 @@ describe(' ', () => {
);
});
- test('click lib component result navigates to the context', async () => {
- const data = generateGetStudioHomeDataApiResponse();
- data.redirectToLibraryAuthoringMfe = true;
- axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data);
-
- await executeThunk(fetchStudioHomeData(), store.dispatch);
-
- const { findByRole } = rendered;
-
- const resultItem = await findByRole('button', { name: /Library Content/ });
-
- // Clicking the "Open in new window" button should open the result in a new window:
- const { open, location } = window;
- window.open = jest.fn();
- fireEvent.click(within(resultItem).getByRole('button', { name: 'Open in new window' }));
- expect(window.open).toHaveBeenCalledWith(
- 'http://localhost:3001/library/lib:org1:libafter1',
- '_blank',
- );
- window.open = open;
-
- // @ts-ignore
- window.location = { href: '' };
- // Clicking in the result should navigate to the result's URL:
- fireEvent.click(resultItem);
- expect(window.location.href = 'http://localhost:3001/library/lib:org1:libafter1');
- window.location = location;
- });
-
- test('click lib component result navigates to course-authoring/library without libraryAuthoringMfe', async () => {
- const data = generateGetStudioHomeDataApiResponse();
- data.redirectToLibraryAuthoringMfe = false;
- data.libraryAuthoringMfeUrl = '';
- axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data);
-
- await executeThunk(fetchStudioHomeData(), store.dispatch);
-
+ test('click lib component result navigates to course-authoring/library', async () => {
const { findByRole } = rendered;
const resultItem = await findByRole('button', { name: /Library Content/ });
diff --git a/src/studio-home/StudioHome.test.jsx b/src/studio-home/StudioHome.test.jsx
index c130d63195..08d4789479 100644
--- a/src/studio-home/StudioHome.test.jsx
+++ b/src/studio-home/StudioHome.test.jsx
@@ -14,7 +14,7 @@ import MockAdapter from 'axios-mock-adapter';
import initializeStore from '../store';
import { RequestStatus } from '../data/constants';
import { COURSE_CREATOR_STATES } from '../constants';
-import { executeThunk, constructLibraryAuthoringURL } from '../utils';
+import { executeThunk } from '../utils';
import { studioHomeMock } from './__mocks__';
import { getStudioHomeApiUrl } from './data/api';
import { fetchStudioHomeData } from './data/thunks';
@@ -193,27 +193,6 @@ describe(' ', () => {
window.open = open;
});
- it('should navigate to the library authoring mfe', () => {
- useSelector.mockReturnValue({
- ...studioHomeMock,
- courseCreatorStatus: COURSE_CREATOR_STATES.granted,
- splitStudioHome: true,
- redirectToLibraryAuthoringMfe: true,
- });
- const libraryAuthoringMfeUrl = 'http://localhost:3001';
-
- const { getByTestId } = render( );
- const createNewLibraryButton = getByTestId('new-library-button');
-
- const { open } = window;
- window.open = jest.fn();
- fireEvent.click(createNewLibraryButton);
- expect(window.open).toHaveBeenCalledWith(
- `${constructLibraryAuthoringURL(libraryAuthoringMfeUrl, 'create')}`,
- );
- window.open = open;
- });
-
it('should navigate to the library authoring page in course authoring', () => {
useSelector.mockReturnValue({
...studioHomeMock,
diff --git a/src/studio-home/StudioHome.tsx b/src/studio-home/StudioHome.tsx
index 9af6ccb2b0..43b8caa703 100644
--- a/src/studio-home/StudioHome.tsx
+++ b/src/studio-home/StudioHome.tsx
@@ -13,7 +13,6 @@ import { StudioFooter } from '@edx/frontend-component-footer';
import { getConfig } from '@edx/frontend-platform';
import { useLocation, useNavigate } from 'react-router-dom';
-import { constructLibraryAuthoringURL } from '../utils';
import Loading from '../generic/Loading';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import Header from '../header';
@@ -58,8 +57,6 @@ const StudioHome = () => {
userIsActive,
studioShortName,
studioRequestEmail,
- libraryAuthoringMfeUrl,
- redirectToLibraryAuthoringMfe,
showNewLibraryButton,
} = studioHomeData;
@@ -93,13 +90,7 @@ const StudioHome = () => {
if (showNewLibraryButton || showV2LibraryURL) {
const newLibraryClick = () => {
if (showV2LibraryURL) {
- if (libraryAuthoringMfeUrl && redirectToLibraryAuthoringMfe) {
- // Library authoring MFE
- window.open(constructLibraryAuthoringURL(libraryAuthoringMfeUrl, 'create'));
- } else {
- // Use course-authoring route
- navigate('/library/create');
- }
+ navigate('/library/create');
} else {
// Studio home library for legacy libraries
window.open(`${getConfig().STUDIO_BASE_URL}/home_library`);
diff --git a/src/studio-home/__mocks__/studioHomeMock.js b/src/studio-home/__mocks__/studioHomeMock.js
index 5385201e52..a811c40518 100644
--- a/src/studio-home/__mocks__/studioHomeMock.js
+++ b/src/studio-home/__mocks__/studioHomeMock.js
@@ -62,9 +62,7 @@ module.exports = {
},
],
librariesEnabled: true,
- libraryAuthoringMfeUrl: 'http://localhost:3001',
optimizationEnabled: false,
- redirectToLibraryAuthoringMfe: false,
requestCourseCreatorUrl: '/request_course_creator',
rerunCreatorStatus: true,
showNewLibraryButton: true,
diff --git a/src/studio-home/card-item/index.tsx b/src/studio-home/card-item/index.tsx
index 35883bb90a..d1bb2921a0 100644
--- a/src/studio-home/card-item/index.tsx
+++ b/src/studio-home/card-item/index.tsx
@@ -62,7 +62,7 @@ const CardItem: React.FC = ({
} = useSelector(getStudioHomeData);
const destinationUrl: string = path ?? new URL(url, getConfig().STUDIO_BASE_URL).toString();
const subtitle = isLibraries ? `${org} / ${number}` : `${org} / ${number} / ${run}`;
- const readOnlyItem = !(lmsLink || rerunLink || url);
+ const readOnlyItem = !(lmsLink || rerunLink || url || path);
const showActions = !(readOnlyItem || isLibraries);
const isShowRerunLink = allowCourseReruns
&& rerunCreatorStatus
diff --git a/src/studio-home/factories/mockApiResponses.jsx b/src/studio-home/factories/mockApiResponses.jsx
index a9993b2cac..7f1d2b4e5f 100644
--- a/src/studio-home/factories/mockApiResponses.jsx
+++ b/src/studio-home/factories/mockApiResponses.jsx
@@ -32,9 +32,7 @@ export const generateGetStudioHomeDataApiResponse = () => ({
inProcessCourseActions: [],
libraries: [],
librariesEnabled: true,
- libraryAuthoringMfeUrl: 'http://localhost:3001/',
optimizationEnabled: false,
- redirectToLibraryAuthoringMfe: false,
requestCourseCreatorUrl: '/request_course_creator',
rerunCreatorStatus: true,
showNewLibraryButton: true,
diff --git a/src/studio-home/tabs-section/TabsSection.test.tsx b/src/studio-home/tabs-section/TabsSection.test.tsx
index 23540af71e..0591f8c7cd 100644
--- a/src/studio-home/tabs-section/TabsSection.test.tsx
+++ b/src/studio-home/tabs-section/TabsSection.test.tsx
@@ -428,22 +428,6 @@ describe(' ', () => {
expect(screen.queryByText(tabMessages.legacyLibrariesTabTitle.defaultMessage)).toBeNull();
});
- it('should redirect to library authoring mfe', async () => {
- const data = generateGetStudioHomeDataApiResponse();
- data.redirectToLibraryAuthoringMfe = true;
-
- render();
- axiosMock.onGet(getStudioHomeApiUrl()).reply(200, data);
- await executeThunk(fetchStudioHomeData(), store.dispatch);
-
- const librariesTab = screen.getByText(tabMessages.legacyLibrariesTabTitle.defaultMessage);
- fireEvent.click(librariesTab);
-
- waitFor(() => {
- expect(window.location.href).toBe(data.libraryAuthoringMfeUrl);
- });
- });
-
it('should render libraries fetch failure alert', async () => {
render();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
diff --git a/src/studio-home/tabs-section/index.tsx b/src/studio-home/tabs-section/index.tsx
index 44e1e9ea29..ab83009a0e 100644
--- a/src/studio-home/tabs-section/index.tsx
+++ b/src/studio-home/tabs-section/index.tsx
@@ -63,8 +63,6 @@ const TabsSection = ({
}, [pathname]);
const {
- libraryAuthoringMfeUrl,
- redirectToLibraryAuthoringMfe,
courses, librariesEnabled, libraries, archivedCourses,
numPages, coursesCount,
} = useSelector(getStudioHomeData);
@@ -125,10 +123,7 @@ const TabsSection = ({
eventKey={TABS_LIST.libraries}
title={intl.formatMessage(messages.librariesTabTitle)}
>
-
+
,
);
}
diff --git a/src/studio-home/tabs-section/libraries-v2-tab/index.tsx b/src/studio-home/tabs-section/libraries-v2-tab/index.tsx
index f29fa1d6c1..0ef4e6d772 100644
--- a/src/studio-home/tabs-section/libraries-v2-tab/index.tsx
+++ b/src/studio-home/tabs-section/libraries-v2-tab/index.tsx
@@ -7,24 +7,18 @@ import {
Button,
} from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
-import { getConfig, getPath } from '@edx/frontend-platform';
import { Error } from '@openedx/paragon/icons';
import { useContentLibraryV2List } from '../../../library-authoring';
-import { constructLibraryAuthoringURL } from '../../../utils';
import { LoadingSpinner } from '../../../generic/Loading';
import AlertMessage from '../../../generic/alert-message';
import CardItem from '../../card-item';
import messages from '../messages';
import LibrariesV2Filters from './libraries-v2-filters';
-const LibrariesV2Tab: React.FC<{
- libraryAuthoringMfeUrl: string,
- redirectToLibraryAuthoringMfe: boolean
-}> = ({
- libraryAuthoringMfeUrl,
- redirectToLibraryAuthoringMfe,
-}) => {
+type Props = Record;
+
+const LibrariesV2Tab: React.FC = () => {
const intl = useIntl();
const [currentPage, setCurrentPage] = useState(1);
@@ -55,15 +49,6 @@ const LibrariesV2Tab: React.FC<{
);
}
- const libURL = (id: string) => (
- libraryAuthoringMfeUrl && redirectToLibraryAuthoringMfe
- ? constructLibraryAuthoringURL(libraryAuthoringMfeUrl, `library/${id}`)
- // Redirection to the placeholder is done in the MFE rather than
- // through the backend i.e. redirection from cms, because this this will probably change,
- // hence why we use the MFE's origin
- : `${window.location.origin}${getPath(getConfig().PUBLIC_PATH)}library/${id}`
- );
-
const hasV2Libraries = !isLoading && ((data!.results.length || 0) > 0);
return (
@@ -109,7 +94,7 @@ const LibrariesV2Tab: React.FC<{
displayName={title}
org={org}
number={slug}
- url={libURL(id)}
+ path={`/library/${id}`}
/>
)) : isFiltered && !isLoading && (
diff --git a/src/utils.js b/src/utils.js
index 2abb63e5be..d4bc8f6ff3 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -301,27 +301,3 @@ export const getFileSizeToClosestByte = (fileSize) => {
const fileSizeFixedDecimal = Number.parseFloat(size).toFixed(2);
return `${fileSizeFixedDecimal} ${units[divides]}`;
};
-
-/**
- * Constructs library authoring MFE URL with correct slashes
- * @param {string} libraryAuthoringMfeUrl - the base library authoring MFE url
- * @param {string} path - the library authoring MFE url path
- * @returns {string} - the correct internal route path
- */
-export const constructLibraryAuthoringURL = (libraryAuthoringMfeUrl, path) => {
- // Remove '/' at the beginning of path if any
- const trimmedPath = path.startsWith('/')
- ? path.slice(1, path.length)
- : path;
-
- let constructedUrl = libraryAuthoringMfeUrl;
- // Remove trailing `/` from base if found
- if (libraryAuthoringMfeUrl.endsWith('/')) {
- constructedUrl = constructedUrl.slice(0, -1);
- }
-
- // Add the `/` and path to url
- constructedUrl = `${constructedUrl}/${trimmedPath}`;
-
- return constructedUrl;
-};
diff --git a/src/utils.test.js b/src/utils.test.js
index a5b12d6c37..e4aada849f 100644
--- a/src/utils.test.js
+++ b/src/utils.test.js
@@ -1,6 +1,6 @@
import { getConfig, getPath } from '@edx/frontend-platform';
-import { getFileSizeToClosestByte, createCorrectInternalRoute, constructLibraryAuthoringURL } from './utils';
+import { getFileSizeToClosestByte, createCorrectInternalRoute } from './utils';
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(),
@@ -78,30 +78,3 @@ describe('FilesAndUploads utils', () => {
});
});
});
-
-describe('constructLibraryAuthoringURL', () => {
- it('should construct URL given no trailing `/` in base and no starting `/` in path', () => {
- const libraryAuthoringMfeUrl = 'http://localhost:3001';
- const path = 'example';
- const constructedURL = constructLibraryAuthoringURL(libraryAuthoringMfeUrl, path);
- expect(constructedURL).toEqual('http://localhost:3001/example');
- });
- it('should construct URL given a trailing `/` in base and no starting `/` in path', () => {
- const libraryAuthoringMfeUrl = 'http://localhost:3001/';
- const path = 'example';
- const constructedURL = constructLibraryAuthoringURL(libraryAuthoringMfeUrl, path);
- expect(constructedURL).toEqual('http://localhost:3001/example');
- });
- it('should construct URL with no trailing `/` in base and a starting `/` in path', () => {
- const libraryAuthoringMfeUrl = 'http://localhost:3001';
- const path = '/example';
- const constructedURL = constructLibraryAuthoringURL(libraryAuthoringMfeUrl, path);
- expect(constructedURL).toEqual('http://localhost:3001/example');
- });
- it('should construct URL with a trailing `/` in base and a starting `/` in path', () => {
- const libraryAuthoringMfeUrl = 'http://localhost:3001/';
- const path = '/example';
- const constructedURL = constructLibraryAuthoringURL(libraryAuthoringMfeUrl, path);
- expect(constructedURL).toEqual('http://localhost:3001/example');
- });
-});