From 3662fadad44a610c95f6169760d48df8305cd6a0 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 26 Sep 2024 08:38:16 -0700 Subject: [PATCH 1/8] feat!: Remove support for the (deprecated) library authoring MFE (#1327) --- src/editors/data/services/cms/api.test.ts | 14 +------ src/editors/data/services/cms/api.ts | 8 ---- src/search-modal/SearchResult.tsx | 10 +---- src/search-modal/SearchUI.test.tsx | 42 +------------------ src/studio-home/StudioHome.test.jsx | 23 +--------- src/studio-home/StudioHome.tsx | 11 +---- src/studio-home/__mocks__/studioHomeMock.js | 2 - src/studio-home/card-item/index.tsx | 2 +- .../factories/mockApiResponses.jsx | 2 - .../tabs-section/TabsSection.test.tsx | 16 ------- src/studio-home/tabs-section/index.tsx | 7 +--- .../tabs-section/libraries-v2-tab/index.tsx | 23 ++-------- src/utils.js | 24 ----------- src/utils.test.js | 29 +------------ 14 files changed, 12 insertions(+), 201 deletions(-) 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/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'); - }); -}); From 95c17537c16cf6d3bb9ad4b3a2742dd23a409728 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 26 Sep 2024 09:50:28 -0700 Subject: [PATCH 2/8] fix: don't revert to advanced problem editor when max_attempts is set (#1326) --- src/editors/containers/ProblemEditor/data/OLXParser.js | 2 +- src/editors/containers/ProblemEditor/data/OLXParser.test.js | 2 +- src/editors/data/constants/problem.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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', From 2cd77ce4559d4577c875a959cb32c741c3facabd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Ch=C3=A1vez?= Date: Fri, 27 Sep 2024 01:33:36 -0500 Subject: [PATCH 3/8] feat: Add tags to manage sidebar of library components (#1299) --- src/content-tags-drawer/ContentTagsDrawer.jsx | 263 ------ .../ContentTagsDrawer.test.jsx | 804 ++++-------------- src/content-tags-drawer/ContentTagsDrawer.tsx | 390 +++++++++ src/content-tags-drawer/data/api.mocks.ts | 378 ++++++++ src/content-tags-drawer/data/apiHooks.jsx | 9 + .../ComponentManagement.test.tsx | 22 +- .../component-info/ComponentManagement.tsx | 30 +- .../component-info/messages.ts | 2 +- src/library-authoring/data/api.mocks.ts | 3 + src/library-authoring/data/apiHooks.ts | 2 +- 10 files changed, 1010 insertions(+), 893 deletions(-) delete mode 100644 src/content-tags-drawer/ContentTagsDrawer.jsx create mode 100644 src/content-tags-drawer/ContentTagsDrawer.tsx create mode 100644 src/content-tags-drawer/data/api.mocks.ts diff --git a/src/content-tags-drawer/ContentTagsDrawer.jsx b/src/content-tags-drawer/ContentTagsDrawer.jsx deleted file mode 100644 index 117ffc3947..0000000000 --- a/src/content-tags-drawer/ContentTagsDrawer.jsx +++ /dev/null @@ -1,263 +0,0 @@ -// @ts-check -import React, { useContext, useEffect } from 'react'; -import PropTypes from 'prop-types'; -import { - Container, - Spinner, - Stack, - Button, - Toast, -} from '@openedx/paragon'; -import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; -import { useParams, useNavigate } from 'react-router-dom'; -import messages from './messages'; -import ContentTagsCollapsible from './ContentTagsCollapsible'; -import Loading from '../generic/Loading'; -import useContentTagsDrawerContext from './ContentTagsDrawerHelper'; -import { ContentTagsDrawerContext, ContentTagsDrawerSheetContext } from './common/context'; - -const TaxonomyList = ({ contentId }) => { - const navigate = useNavigate(); - const intl = useIntl(); - - const { - isTaxonomyListLoaded, - isContentTaxonomyTagsLoaded, - tagsByTaxonomy, - stagedContentTags, - collapsibleStates, - } = React.useContext(ContentTagsDrawerContext); - - if (isTaxonomyListLoaded && isContentTaxonomyTagsLoaded) { - if (tagsByTaxonomy.length !== 0) { - return ( -
- { tagsByTaxonomy.map((data) => ( -
- -
-
- ))} -
- ); - } - - return ( - navigate('/taxonomies')} - > - { intl.formatMessage(messages.emptyDrawerContentLink) } - - ), - }} - /> - ); - } - - return ; -}; - -TaxonomyList.propTypes = { - contentId: PropTypes.string.isRequired, -}; - -/** - * Drawer with the functionality to show and manage tags in a certain content. - * It is used both in interfaces of this MFE and in edx-platform interfaces such as iframe. - * - If you want to use it as an iframe, the component obtains the `contentId` from the url parameters. - * Functions to close the drawer are handled internally. - * TODO: We can delete this method when is no longer used on edx-platform. - * - If you want to use it as react component, you need to pass the content id and the close functions - * through the component parameters. - */ -const ContentTagsDrawer = ({ id, onClose }) => { - const intl = useIntl(); - // TODO: We can delete 'params' when the iframe is no longer used on edx-platform - const params = useParams(); - const contentId = id ?? params.contentId; - - const context = useContentTagsDrawerContext(contentId); - const { blockingSheet } = useContext(ContentTagsDrawerSheetContext); - - const { - showToastAfterSave, - toReadMode, - commitGlobalStagedTagsStatus, - isContentDataLoaded, - contentName, - isTaxonomyListLoaded, - isContentTaxonomyTagsLoaded, - stagedContentTags, - collapsibleStates, - isEditMode, - commitGlobalStagedTags, - toEditMode, - toastMessage, - closeToast, - setCollapsibleToInitalState, - otherTaxonomies, - } = context; - - let onCloseDrawer = onClose; - if (onCloseDrawer === undefined) { - onCloseDrawer = () => { - // "*" allows communication with any origin - window.parent.postMessage('closeManageTagsDrawer', '*'); - }; - } - - useEffect(() => { - const handleEsc = (event) => { - /* Close drawer when ESC-key is pressed and selectable dropdown box not open */ - const selectableBoxOpen = document.querySelector('[data-selectable-box="taxonomy-tags"]'); - if (event.key === 'Escape' && !selectableBoxOpen && !blockingSheet) { - onCloseDrawer(); - } - }; - document.addEventListener('keydown', handleEsc); - - return () => { - document.removeEventListener('keydown', handleEsc); - }; - }, [blockingSheet]); - - useEffect(() => { - /* istanbul ignore next */ - if (commitGlobalStagedTagsStatus === 'success') { - showToastAfterSave(); - toReadMode(); - } - }, [commitGlobalStagedTagsStatus]); - - // First call of the initial collapsible states - React.useEffect(() => { - setCollapsibleToInitalState(); - }, [isTaxonomyListLoaded, isContentTaxonomyTagsLoaded]); - - return ( - -
- - { isContentDataLoaded - ?

{ contentName }

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

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

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

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

-

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

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

{ contentName }

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

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

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

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

+

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

+ { isTaxonomyListLoaded && isContentTaxonomyTagsLoaded && ( + otherTaxonomies.map((data) => ( +
+ +
+
+ )) + )} +
+ )} +
+
+ {renderFooter()} + {/* istanbul ignore next */ + toastMessage && ( + + {toastMessage} + + ) + } +
+
+ ); +}; + +export default ContentTagsDrawer; diff --git a/src/content-tags-drawer/data/api.mocks.ts b/src/content-tags-drawer/data/api.mocks.ts new file mode 100644 index 0000000000..ce1d50c05c --- /dev/null +++ b/src/content-tags-drawer/data/api.mocks.ts @@ -0,0 +1,378 @@ +import * as api from './api'; +import * as taxonomyApi from '../../taxonomy/data/api'; +import { languageExportId } from '../utils'; + +/** + * Mock for `getContentTaxonomyTagsData()` + */ +export async function mockContentTaxonomyTagsData(contentId: string): Promise { + const thisMock = mockContentTaxonomyTagsData; + switch (contentId) { + case thisMock.stagedTagsId: return thisMock.stagedTags; + case thisMock.otherTagsId: return thisMock.otherTags; + case thisMock.languageWithTagsId: return thisMock.languageWithTags; + case thisMock.languageWithoutTagsId: return thisMock.languageWithoutTags; + case thisMock.largeTagsId: return thisMock.largeTags; + case thisMock.emptyTagsId: return thisMock.emptyTags; + default: throw new Error(`No mock has been set up for contentId "${contentId}"`); + } +} +mockContentTaxonomyTagsData.stagedTagsId = 'block-v1:StagedTagsOrg+STC1+2023_1+type@vertical+block@stagedTagsId'; +mockContentTaxonomyTagsData.stagedTags = { + taxonomies: [ + { + name: 'Taxonomy 1', + taxonomyId: 123, + canTagObject: true, + tags: [ + { + value: 'Tag 1', + lineage: ['Tag 1'], + canDeleteObjecttag: true, + }, + { + value: 'Tag 2', + lineage: ['Tag 2'], + canDeleteObjecttag: true, + }, + ], + }, + ], +}; +mockContentTaxonomyTagsData.otherTagsId = 'block-v1:StagedTagsOrg+STC1+2023_1+type@vertical+block@otherTagsId'; +mockContentTaxonomyTagsData.otherTags = { + taxonomies: [ + { + name: 'Taxonomy 1', + taxonomyId: 123, + canTagObject: true, + tags: [ + { + value: 'Tag 1', + lineage: ['Tag 1'], + canDeleteObjecttag: true, + }, + { + value: 'Tag 2', + lineage: ['Tag 2'], + canDeleteObjecttag: true, + }, + ], + }, + { + name: 'Taxonomy 2', + taxonomyId: 1234, + canTagObject: false, + tags: [ + { + value: 'Tag 3', + lineage: ['Tag 3'], + canDeleteObjecttag: true, + }, + { + value: 'Tag 4', + lineage: ['Tag 4'], + canDeleteObjecttag: true, + }, + ], + }, + ], +}; +mockContentTaxonomyTagsData.languageWithTagsId = 'block-v1:LanguageTagsOrg+STC1+2023_1+type@vertical+block@languageWithTagsId'; +mockContentTaxonomyTagsData.languageWithTags = { + taxonomies: [ + { + name: 'Languages', + taxonomyId: 1234, + exportId: languageExportId, + canTagObject: true, + tags: [ + { + value: 'Tag 1', + lineage: ['Tag 1'], + canDeleteObjecttag: true, + }, + ], + }, + { + name: 'Taxonomy 1', + taxonomyId: 12345, + canTagObject: true, + tags: [ + { + value: 'Tag 1', + lineage: ['Tag 1'], + canDeleteObjecttag: true, + }, + { + value: 'Tag 2', + lineage: ['Tag 2'], + canDeleteObjecttag: true, + }, + ], + }, + ], +}; +mockContentTaxonomyTagsData.languageWithoutTagsId = 'block-v1:LanguageTagsOrg+STC1+2023_1+type@vertical+block@languageWithoutTagsId'; +mockContentTaxonomyTagsData.languageWithoutTags = { + taxonomies: [ + { + name: 'Languages', + taxonomyId: 1234, + exportId: languageExportId, + canTagObject: true, + tags: [], + }, + { + name: 'Taxonomy 1', + taxonomyId: 12345, + canTagObject: true, + tags: [ + { + value: 'Tag 1', + lineage: ['Tag 1'], + canDeleteObjecttag: true, + }, + { + value: 'Tag 2', + lineage: ['Tag 2'], + canDeleteObjecttag: true, + }, + ], + }, + ], +}; +mockContentTaxonomyTagsData.largeTagsId = 'block-v1:LargeTagsOrg+STC1+2023_1+type@vertical+block@largeTagsId'; +mockContentTaxonomyTagsData.largeTags = { + taxonomies: [ + { + name: 'Taxonomy 1', + taxonomyId: 123, + canTagObject: true, + tags: [ + { + value: 'Tag 1', + lineage: ['Tag 1'], + canDeleteObjecttag: true, + }, + { + value: 'Tag 2', + lineage: ['Tag 2'], + canDeleteObjecttag: true, + }, + ], + }, + { + name: 'Taxonomy 2', + taxonomyId: 124, + canTagObject: true, + tags: [ + { + value: 'Tag 1', + lineage: ['Tag 1'], + canDeleteObjecttag: true, + }, + ], + }, + { + name: 'Taxonomy 3', + taxonomyId: 125, + canTagObject: true, + tags: [ + { + value: 'Tag 1.1.1', + lineage: ['Tag 1', 'Tag 1.1', 'Tag 1.1.1'], + canDeleteObjecttag: true, + }, + ], + }, + { + name: '(B) Taxonomy 4', + taxonomyId: 126, + canTagObject: true, + tags: [], + }, + { + name: '(A) Taxonomy 5', + taxonomyId: 127, + canTagObject: true, + tags: [], + }, + ], +}; +mockContentTaxonomyTagsData.emptyTagsId = 'block-v1:EmptyTagsOrg+STC1+2023_1+type@vertical+block@emptyTagsId'; +mockContentTaxonomyTagsData.emptyTags = { + taxonomies: [], +}; +mockContentTaxonomyTagsData.applyMock = () => jest.spyOn(api, 'getContentTaxonomyTagsData').mockImplementation(mockContentTaxonomyTagsData); + +/** + * Mock for `getTaxonomyListData()` + */ +export async function mockTaxonomyListData(org: string): Promise { + const thisMock = mockTaxonomyListData; + switch (org) { + case thisMock.stagedTagsOrg: return thisMock.stagedTags; + case thisMock.languageTagsOrg: return thisMock.languageTags; + case thisMock.largeTagsOrg: return thisMock.largeTags; + case thisMock.emptyTagsOrg: return thisMock.emptyTags; + default: throw new Error(`No mock has been set up for org "${org}"`); + } +} +mockTaxonomyListData.stagedTagsOrg = 'StagedTagsOrg'; +mockTaxonomyListData.stagedTags = { + results: [ + { + id: 123, + name: 'Taxonomy 1', + description: 'This is a description 1', + canTagObject: true, + }, + ], +}; +mockTaxonomyListData.languageTagsOrg = 'LanguageTagsOrg'; +mockTaxonomyListData.languageTags = { + results: [ + { + id: 1234, + name: 'Languages', + description: 'This is a description 1', + exportId: languageExportId, + canTagObject: true, + }, + { + id: 12345, + name: 'Taxonomy 1', + description: 'This is a description 2', + canTagObject: true, + }, + ], +}; +mockTaxonomyListData.largeTagsOrg = 'LargeTagsOrg'; +mockTaxonomyListData.largeTags = { + results: [ + { + id: 123, + name: 'Taxonomy 1', + description: 'This is a description 1', + canTagObject: true, + }, + { + id: 124, + name: 'Taxonomy 2', + description: 'This is a description 2', + canTagObject: true, + }, + { + id: 125, + name: 'Taxonomy 3', + description: 'This is a description 3', + canTagObject: true, + }, + { + id: 127, + name: '(A) Taxonomy 5', + description: 'This is a description 5', + canTagObject: true, + }, + { + id: 126, + name: '(B) Taxonomy 4', + description: 'This is a description 4', + canTagObject: true, + }, + ], +}; +mockTaxonomyListData.emptyTagsOrg = 'EmptyTagsOrg'; +mockTaxonomyListData.emptyTags = { + results: [], +}; +mockTaxonomyListData.applyMock = () => jest.spyOn(taxonomyApi, 'getTaxonomyListData').mockImplementation(mockTaxonomyListData); + +/** + * Mock for `getTaxonomyTagsData()` + */ +export async function mockTaxonomyTagsData(taxonomyId: number): Promise { + const thisMock = mockTaxonomyTagsData; + switch (taxonomyId) { + case thisMock.stagedTagsTaxonomy: return thisMock.stagedTags; + case thisMock.languageTagsTaxonomy: return thisMock.languageTags; + default: throw new Error(`No mock has been set up for taxonomyId "${taxonomyId}"`); + } +} +mockTaxonomyTagsData.stagedTagsTaxonomy = 123; +mockTaxonomyTagsData.stagedTags = { + count: 3, + currentPage: 1, + next: null, + numPages: 1, + previous: null, + start: 1, + results: [ + { + value: 'Tag 1', + externalId: null, + childCount: 0, + depth: 0, + parentValue: null, + id: 12345, + subTagsUrl: null, + canChangeTag: false, + canDeleteTag: false, + }, + { + value: 'Tag 2', + externalId: null, + childCount: 0, + depth: 0, + parentValue: null, + id: 12346, + subTagsUrl: null, + canChangeTag: false, + canDeleteTag: false, + }, + { + value: 'Tag 3', + externalId: null, + childCount: 0, + depth: 0, + parentValue: null, + id: 12347, + subTagsUrl: null, + canChangeTag: false, + canDeleteTag: false, + }, + ], +}; +mockTaxonomyTagsData.languageTagsTaxonomy = 1234; +mockTaxonomyTagsData.languageTags = { + count: 1, + currentPage: 1, + next: null, + numPages: 1, + previous: null, + start: 1, + results: [{ + value: 'Tag 1', + externalId: null, + childCount: 0, + depth: 0, + parentValue: null, + id: 12345, + subTagsUrl: null, + canChangeTag: false, + canDeleteTag: false, + }], +}; +mockTaxonomyTagsData.applyMock = () => jest.spyOn(api, 'getTaxonomyTagsData').mockImplementation(mockTaxonomyTagsData); + +/** + * Mock for `getContentData()` + */ +export async function mockContentData(): Promise { + return mockContentData.data; +} +mockContentData.data = { + displayName: 'Unit 1', +}; +mockContentData.applyMock = () => jest.spyOn(api, 'getContentData').mockImplementation(mockContentData); diff --git a/src/content-tags-drawer/data/apiHooks.jsx b/src/content-tags-drawer/data/apiHooks.jsx index a9e09ae85e..34f70bb3f4 100644 --- a/src/content-tags-drawer/data/apiHooks.jsx +++ b/src/content-tags-drawer/data/apiHooks.jsx @@ -14,6 +14,7 @@ import { updateContentTaxonomyTags, getContentTaxonomyTagsCount, } from './api'; +import { libraryQueryPredicate, xblockQueryKeys } from '../../library-authoring/data/apiHooks'; /** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagListData} TagListData */ /** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagData} TagData */ @@ -146,6 +147,14 @@ export const useContentTaxonomyTagsUpdater = (contentId) => { contentPattern = contentId.replace(/\+type@.*$/, '*'); } queryClient.invalidateQueries({ queryKey: ['contentTagsCount', contentPattern] }); + if (contentId.includes('lb:')) { + // Obtain library id from contentId + const libraryId = ['lib', ...contentId.split(':').slice(1, 3)].join(':'); + // Invalidate component metadata to update tags count + queryClient.invalidateQueries(xblockQueryKeys.componentMetadata(contentId)); + // Invalidate content search to update tags count + queryClient.invalidateQueries(['content_search'], { predicate: (query) => libraryQueryPredicate(query, libraryId) }); + } }, onSuccess: /* istanbul ignore next */ () => { /* istanbul ignore next */ diff --git a/src/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 + )} jest.spyOn(api, 'getLibraryBlockMetadata').mockImplementation(mockLibraryBlockMetadata); diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 96b7122af8..cb8cdd2fba 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -30,7 +30,7 @@ import { type CreateLibraryCollectionDataRequest, } 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"]` From c80483c0533a10e46c59e9bd28e02a7650720a61 Mon Sep 17 00:00:00 2001 From: Dmytro <98233552+DmytroAlipov@users.noreply.github.com> Date: Fri, 27 Sep 2024 17:37:32 +0200 Subject: [PATCH 4/8] fix: Create button remains deactivated until pick a new org (#1279) Co-authored-by: Dima Alipov --- src/editors/sharedComponents/TypeaheadDropdown/index.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 }); } } From 4d67e8bda9ba2f33cbe45697b9e496142433cdfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Fri, 27 Sep 2024 23:24:12 -0300 Subject: [PATCH 5/8] feat: improve collection sidebar (#1320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: improve collection sidebar * feat: add comments to splice blockTypesArray code Co-authored-by: Jillian --------- Co-authored-by: Jillian Co-authored-by: Chris Chávez --- .../LibraryAuthoringPage.test.tsx | 27 ++- .../__mocks__/collection-search.json | 2 +- .../__mocks__/library-search.json | 1 + .../collections/CollectionDetails.test.tsx | 161 ++++++++++++++++ .../collections/CollectionDetails.tsx | 177 ++++++++++++++++++ .../collections/CollectionInfo.tsx | 56 ++++-- .../collections/CollectionInfoHeader.test.tsx | 157 ++++++++++++++++ .../collections/CollectionInfoHeader.tsx | 102 +++++++++- .../LibraryCollectionPage.test.tsx | 50 ++--- .../collections/LibraryCollectionPage.tsx | 116 ++++++------ src/library-authoring/collections/messages.ts | 55 ++++++ src/library-authoring/common/context.tsx | 27 ++- .../components/CollectionCard.test.tsx | 23 ++- .../components/CollectionCard.tsx | 48 ++++- src/library-authoring/components/messages.ts | 5 + src/library-authoring/data/api.mocks.ts | 29 +++ src/library-authoring/data/api.ts | 24 ++- src/library-authoring/data/apiHooks.test.tsx | 17 ++ src/library-authoring/data/apiHooks.ts | 30 +++ .../generic/history-widget/index.tsx | 6 +- src/library-authoring/generic/index.scss | 2 +- .../library-sidebar/LibrarySidebar.tsx | 22 ++- src/search-manager/SearchManager.ts | 2 +- .../data/__mocks__/block-types.json | 24 +++ src/search-manager/data/api.mock.ts | 24 +++ src/search-manager/data/api.ts | 29 ++- src/search-manager/data/apiHooks.test.tsx | 57 ++++++ src/search-manager/data/apiHooks.ts | 17 ++ src/search-manager/index.ts | 2 + 29 files changed, 1154 insertions(+), 138 deletions(-) create mode 100644 src/library-authoring/collections/CollectionDetails.test.tsx create mode 100644 src/library-authoring/collections/CollectionDetails.tsx create mode 100644 src/library-authoring/collections/CollectionInfoHeader.test.tsx create mode 100644 src/search-manager/data/__mocks__/block-types.json create mode 100644 src/search-manager/data/apiHooks.test.tsx diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index 4bd1542856..b2b044429a 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 ? ( +