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"]`