feat: Add tags to manage sidebar of library components (#1299)

This commit is contained in:
Chris Chávez
2024-09-27 01:33:36 -05:00
committed by GitHub
parent 95c17537c1
commit 2cd77ce455
10 changed files with 1009 additions and 892 deletions

View File

@@ -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 (
<div>
{ tagsByTaxonomy.map((data) => (
<div key={`taxonomy-tags-collapsible-${data.id}`}>
<ContentTagsCollapsible
contentId={contentId}
taxonomyAndTagsData={data}
stagedContentTags={stagedContentTags[data.id] || []}
collapsibleState={collapsibleStates[data.id] || false}
/>
<hr />
</div>
))}
</div>
);
}
return (
<FormattedMessage
{...messages.emptyDrawerContent}
values={{
link: (
<Button
tabIndex={0}
size="inline"
variant="link"
className="text-info-500 p-0 enable-taxonomies-button"
onClick={() => navigate('/taxonomies')}
>
{ intl.formatMessage(messages.emptyDrawerContentLink) }
</Button>
),
}}
/>
);
}
return <Loading />;
};
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 (
<ContentTagsDrawerContext.Provider value={context}>
<div id="content-tags-drawer" className="mt-1 tags-drawer d-flex flex-column justify-content-between min-vh-100 pt-3">
<Container size="xl">
{ isContentDataLoaded
? <h2 className="h3 pl-2.5">{ contentName }</h2>
: (
<div className="d-flex justify-content-center align-items-center flex-column">
<Spinner
animation="border"
size="xl"
screenReaderText={intl.formatMessage(messages.loadingMessage)}
/>
</div>
)}
<hr />
<Container>
<p className="h4 text-gray-500 font-weight-bold">
{intl.formatMessage(messages.headerSubtitle)}
</p>
<TaxonomyList contentId={contentId} />
{otherTaxonomies.length !== 0 && (
<div>
<p className="h4 text-gray-500 font-weight-bold">
{intl.formatMessage(messages.otherTagsHeader)}
</p>
<p className="other-description text-gray-500">
{intl.formatMessage(messages.otherTagsDescription)}
</p>
{ isTaxonomyListLoaded && isContentTaxonomyTagsLoaded && (
otherTaxonomies.map((data) => (
<div key={`taxonomy-tags-collapsible-${data.id}`}>
<ContentTagsCollapsible
contentId={contentId}
taxonomyAndTagsData={data}
stagedContentTags={stagedContentTags[data.id] || []}
collapsibleState={collapsibleStates[data.id] || false}
/>
<hr />
</div>
))
)}
</div>
)}
</Container>
</Container>
{ isTaxonomyListLoaded && isContentTaxonomyTagsLoaded && (
<Container
className="bg-white position-sticky p-3.5 box-shadow-up-2 tags-drawer-footer"
>
<div className="d-flex justify-content-end">
{ commitGlobalStagedTagsStatus !== 'loading' ? (
<Stack direction="horizontal" gap={2}>
<Button
className="font-weight-bold tags-drawer-cancel-button"
variant="tertiary"
onClick={isEditMode
? toReadMode
: onCloseDrawer}
>
{ intl.formatMessage(isEditMode
? messages.tagsDrawerCancelButtonText
: messages.tagsDrawerCloseButtonText)}
</Button>
<Button
variant="dark"
className="rounded-0"
onClick={isEditMode
? commitGlobalStagedTags
: toEditMode}
>
{ intl.formatMessage(isEditMode
? messages.tagsDrawerSaveButtonText
: messages.tagsDrawerEditTagsButtonText)}
</Button>
</Stack>
)
: (
<Spinner
animation="border"
size="xl"
screenReaderText={intl.formatMessage(messages.loadingMessage)}
/>
)}
</div>
</Container>
)}
{/* istanbul ignore next */
toastMessage && (
<Toast
show
onClose={closeToast}
>
{toastMessage}
</Toast>
)
}
</div>
</ContentTagsDrawerContext.Provider>
);
};
ContentTagsDrawer.propTypes = {
id: PropTypes.string,
onClose: PropTypes.func,
};
ContentTagsDrawer.defaultProps = {
id: undefined,
onClose: undefined,
};
export default ContentTagsDrawer;

View File

@@ -1,589 +1,123 @@
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';
import {
mockContentData,
mockContentTaxonomyTagsData,
mockTaxonomyListData,
mockTaxonomyTagsData,
} from './data/api.mocks';
import { getContentTaxonomyTagsApiUrl } from './data/api';
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab';
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) => (
<ContentTagsDrawerSheetContext.Provider value={params}>
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
<ContentTagsDrawer {...params} />
</QueryClientProvider>
</IntlProvider>
</ContentTagsDrawerSheetContext.Provider>
const renderDrawer = (contentId, drawerParams = {}) => (
render(
<ContentTagsDrawerSheetContext.Provider value={drawerParams}>
<ContentTagsDrawer {...drawerParams} />
</ContentTagsDrawerSheetContext.Provider>,
{ path, params: { contentId } },
)
);
describe('<ContentTagsDrawer />', () => {
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(<RootWrapper />);
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(<RootWrapper />);
const spinner = getAllByRole('status')[0];
renderDrawer(stagedTagsId);
const spinner = screen.getAllByRole('status')[0];
expect(spinner.textContent).toEqual('Loading'); // Uses <Spinner />
});
});
it('shows spinner before the taxonomy tags query is complete', async () => {
await act(async () => {
const { getAllByRole } = render(<RootWrapper />);
const spinner = getAllByRole('status')[1];
renderDrawer(stagedTagsId);
const spinner = screen.getAllByRole('status')[1];
expect(spinner.textContent).toEqual('Loading...'); // Uses <Loading />
});
});
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(<RootWrapper />);
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(<RootWrapper id={contentId} />);
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(<RootWrapper />);
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(<RootWrapper />);
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();
@@ -598,9 +132,26 @@ describe('<ContentTagsDrawer />', () => {
expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument();
});
it('should change to edit mode when click on `Edit tags`', async () => {
setupMockDataForStagedTagsTesting();
render(<RootWrapper />);
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();
// 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 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('<ContentTagsDrawer />', () => {
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
it('should change to read mode when click on `Cancel`', async () => {
setupMockDataForStagedTagsTesting();
render(<RootWrapper />);
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('<ContentTagsDrawer />', () => {
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(<RootWrapper />);
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(<RootWrapper />);
renderDrawer(stagedTagsId);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
// To edit mode
@@ -678,7 +264,7 @@ describe('<ContentTagsDrawer />', () => {
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('<ContentTagsDrawer />', () => {
});
it('should test removing a staged content from a taxonomy', async () => {
setupMockDataForStagedTagsTesting();
render(<RootWrapper />);
renderDrawer(stagedTagsId);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
// To edit mode
@@ -705,7 +290,7 @@ describe('<ContentTagsDrawer />', () => {
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('<ContentTagsDrawer />', () => {
});
it('should test clearing staged tags for a taxonomy', async () => {
setupMockDataForStagedTagsTesting();
const {
container,
} = render(<RootWrapper />);
} = renderDrawer(stagedTagsId);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
// To edit mode
@@ -739,7 +322,7 @@ describe('<ContentTagsDrawer />', () => {
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('<ContentTagsDrawer />', () => {
});
it('should test adding global staged tags and cancel', async () => {
setupMockDataForStagedTagsTesting();
render(<RootWrapper />);
renderDrawer(stagedTagsId);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
// To edit mode
@@ -774,7 +356,7 @@ describe('<ContentTagsDrawer />', () => {
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('<ContentTagsDrawer />', () => {
});
it('should test delete feched tags and cancel', async () => {
setupMockDataForStagedTagsTesting();
render(<RootWrapper />);
renderDrawer(stagedTagsId);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
// To edit mode
@@ -802,7 +383,7 @@ describe('<ContentTagsDrawer />', () => {
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('<ContentTagsDrawer />', () => {
});
it('should test delete global staged tags and cancel', async () => {
setupMockDataForStagedTagsTesting();
render(<RootWrapper />);
renderDrawer(stagedTagsId);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
// To edit mode
@@ -834,7 +414,7 @@ describe('<ContentTagsDrawer />', () => {
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('<ContentTagsDrawer />', () => {
});
it('should test add removed feched tags and cancel', async () => {
setupMockDataForStagedTagsTesting();
render(<RootWrapper />);
renderDrawer(stagedTagsId);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
// To edit mode
@@ -871,7 +450,7 @@ describe('<ContentTagsDrawer />', () => {
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('<ContentTagsDrawer />', () => {
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('<ContentTagsDrawer />', () => {
});
it('should call onClose when cancel is clicked', async () => {
setupMockDataForStagedTagsTesting();
render(<RootWrapper onClose={mockOnClose} />);
renderDrawer(stagedTagsId, { onClose: mockOnClose });
const cancelButton = await screen.findByRole('button', {
name: /close/i,
@@ -917,7 +495,7 @@ describe('<ContentTagsDrawer />', () => {
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(<RootWrapper />);
const { container } = renderDrawer(stagedTagsId);
fireEvent.keyDown(container, {
key: 'Escape',
@@ -929,7 +507,7 @@ describe('<ContentTagsDrawer />', () => {
});
it('should call `onClose` when Escape key is pressed and no selectable box is active', () => {
const { container } = render(<RootWrapper onClose={mockOnClose} />);
const { container } = renderDrawer(stagedTagsId, { onClose: mockOnClose });
fireEvent.keyDown(container, {
key: 'Escape',
@@ -941,7 +519,7 @@ describe('<ContentTagsDrawer />', () => {
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(<RootWrapper />);
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('<ContentTagsDrawer />', () => {
});
it('should not call `onClose` when Escape key is pressed and a selectable box is active', () => {
const { container } = render(<RootWrapper onClose={mockOnClose} />);
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('<ContentTagsDrawer />', () => {
it('should not call closeManageTagsDrawer when Escape key is pressed and container is blocked', () => {
const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
const { container } = render(<RootWrapper blockingSheet />);
const { container } = renderDrawer(stagedTagsId, { blockingSheet: true });
fireEvent.keyDown(container, {
key: 'Escape',
});
@@ -992,7 +569,10 @@ describe('<ContentTagsDrawer />', () => {
});
it('should not call `onClose` when Escape key is pressed and container is blocked', () => {
const { container } = render(<RootWrapper blockingSheet onClose={mockOnClose} />);
const { container } = renderDrawer(stagedTagsId, {
blockingSheet: true,
onClose: mockOnClose,
});
fireEvent.keyDown(container, {
key: 'Escape',
});
@@ -1001,8 +581,10 @@ describe('<ContentTagsDrawer />', () => {
});
it('should call `setBlockingSheet` on add a tag', async () => {
setupMockDataForStagedTagsTesting();
render(<RootWrapper blockingSheet setBlockingSheet={mockSetBlockingSheet} />);
renderDrawer(stagedTagsId, {
blockingSheet: true,
setBlockingSheet: mockSetBlockingSheet,
});
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
expect(mockSetBlockingSheet).toHaveBeenCalledWith(false);
@@ -1019,7 +601,7 @@ describe('<ContentTagsDrawer />', () => {
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('<ContentTagsDrawer />', () => {
});
it('should call `setBlockingSheet` on delete a tag', async () => {
setupMockDataForStagedTagsTesting();
render(<RootWrapper blockingSheet setBlockingSheet={mockSetBlockingSheet} />);
renderDrawer(stagedTagsId, {
blockingSheet: true,
setBlockingSheet: mockSetBlockingSheet,
});
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
expect(mockSetBlockingSheet).toHaveBeenCalledWith(false);
@@ -1053,8 +637,10 @@ describe('<ContentTagsDrawer />', () => {
});
it('should call `updateTags` mutation on save', async () => {
setupMockDataForStagedTagsTesting();
render(<RootWrapper />);
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('<ContentTagsDrawer />', () => {
});
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(<RootWrapper />);
renderDrawer(largeTagsId);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
// First, taxonomies with content sorted by count implicit
@@ -1091,18 +676,14 @@ describe('<ContentTagsDrawer />', () => {
});
it('should not show "Other tags" section', async () => {
setupMockDataForStagedTagsTesting();
render(<RootWrapper />);
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(<RootWrapper />);
renderDrawer(otherTagsId);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
expect(screen.getByText('Other tags')).toBeInTheDocument();
@@ -1112,8 +693,7 @@ describe('<ContentTagsDrawer />', () => {
});
it('should test delete "Other tags" and cancel', async () => {
setupMockDataWithOtherTagsTestings();
render(<RootWrapper />);
renderDrawer(otherTagsId);
expect(await screen.findByText('Taxonomy 2')).toBeInTheDocument();
// To edit mode
@@ -1139,40 +719,18 @@ describe('<ContentTagsDrawer />', () => {
});
it('should show Language Taxonomy', async () => {
setupMockDataLanguageTaxonomyTestings(true);
render(<RootWrapper />);
renderDrawer(languageWithTagsId);
expect(await screen.findByText('Languages')).toBeInTheDocument();
});
it('should hide Language Taxonomy', async () => {
setupMockDataLanguageTaxonomyTestings(false);
render(<RootWrapper />);
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(<RootWrapper />);
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,

View File

@@ -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 (
<div>
{ tagsByTaxonomy.map((data) => (
<div key={data.id}>
<ContentTagsCollapsible
contentId={contentId}
taxonomyAndTagsData={data}
stagedContentTags={stagedContentTags[data.id] || []}
collapsibleState={collapsibleStates[data.id] || false}
/>
<hr />
</div>
))}
</div>
);
}
return (
<FormattedMessage
{...messages.emptyDrawerContent}
values={{
link: (
<Button
tabIndex={0}
size="inline"
variant="link"
className="text-info-500 p-0 enable-taxonomies-button"
onClick={() => navigate('/taxonomies')}
>
{ intl.formatMessage(messages.emptyDrawerContentLink) }
</Button>
),
}}
/>
);
}
return <Loading />;
};
const ContentTagsDrawerTittle = () => {
const intl = useIntl();
const {
isContentDataLoaded,
contentName,
} = useContext(ContentTagsDrawerContext);
return (
<>
{ isContentDataLoaded
? <h2 className="h3 pl-2.5">{ contentName }</h2>
: (
<div className="d-flex justify-content-center align-items-center flex-column">
<Spinner
animation="border"
size="xl"
screenReaderText={intl.formatMessage(messages.loadingMessage)}
/>
</div>
)}
<hr />
</>
);
};
interface ContentTagsDrawerVariantFooterProps {
onClose: () => void,
}
const ContentTagsDrawerVariantFooter = ({ onClose }: ContentTagsDrawerVariantFooterProps) => {
const intl = useIntl();
const {
commitGlobalStagedTagsStatus,
commitGlobalStagedTags,
isEditMode,
toReadMode,
toEditMode,
} = useContext(ContentTagsDrawerContext);
return (
<Container
className="bg-white position-sticky p-3.5 box-shadow-up-2 tags-drawer-footer"
>
<div className="d-flex justify-content-end">
{ commitGlobalStagedTagsStatus !== 'loading' ? (
<Stack direction="horizontal" gap={2}>
<Button
className="font-weight-bold tags-drawer-cancel-button"
variant="tertiary"
onClick={isEditMode
? toReadMode
: onClose}
>
{ intl.formatMessage(isEditMode
? messages.tagsDrawerCancelButtonText
: messages.tagsDrawerCloseButtonText)}
</Button>
<Button
className="rounded-0"
onClick={isEditMode
? commitGlobalStagedTags
: toEditMode}
>
{ intl.formatMessage(isEditMode
? messages.tagsDrawerSaveButtonText
: messages.tagsDrawerEditTagsButtonText)}
</Button>
</Stack>
)
: (
<Spinner
animation="border"
size="xl"
screenReaderText={intl.formatMessage(messages.loadingMessage)}
/>
)}
</div>
</Container>
);
};
const ContentTagsComponentVariantFooter = () => {
const intl = useIntl();
const {
commitGlobalStagedTagsStatus,
commitGlobalStagedTags,
isEditMode,
toReadMode,
toEditMode,
} = useContext(ContentTagsDrawerContext);
return (
<div>
{isEditMode ? (
<div>
{ commitGlobalStagedTagsStatus !== 'loading' ? (
<Stack direction="horizontal" gap={2}>
<Button
className="font-weight-bold tags-drawer-cancel-button"
variant="tertiary"
onClick={toReadMode}
>
{intl.formatMessage(messages.tagsDrawerCancelButtonText)}
</Button>
<Button
className="rounded-0"
onClick={commitGlobalStagedTags}
block
>
{intl.formatMessage(messages.tagsDrawerSaveButtonText)}
</Button>
</Stack>
) : (
<div className="d-flex justify-content-center">
<Spinner
animation="border"
size="xl"
screenReaderText={intl.formatMessage(messages.loadingMessage)}
/>
</div>
)}
</div>
) : (
<Button
variant="outline-primary"
onClick={toEditMode}
block
>
{intl.formatMessage(messages.manageTagsButton)}
</Button>
)}
</div>
);
};
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 <ContentTagsDrawerVariantFooter onClose={onCloseDrawer} />;
case 'component':
return <ContentTagsComponentVariantFooter />;
default:
return null;
}
}
return null;
};
return (
<ContentTagsDrawerContext.Provider value={context}>
<div
id="content-tags-drawer"
className={classNames(
'mt-1 tags-drawer d-flex flex-column justify-content-between pt-3',
{
'min-vh-100': variant === 'drawer',
},
)}
>
<Container
size="xl"
className={classNames(
{
'p-0': variant === 'component',
},
)}
>
{variant === 'drawer' && (
<ContentTagsDrawerTittle />
)}
<Container
className={classNames(
{
'p-0': variant === 'component',
},
)}
>
{variant === 'drawer' && (
<p className="h4 text-gray-500 font-weight-bold">
{intl.formatMessage(messages.headerSubtitle)}
</p>
)}
<TaxonomyList contentId={contentId} />
{otherTaxonomies.length !== 0 && (
<div>
<p className="h4 text-gray-500 font-weight-bold">
{intl.formatMessage(messages.otherTagsHeader)}
</p>
<p className="other-description text-gray-500">
{intl.formatMessage(messages.otherTagsDescription)}
</p>
{ isTaxonomyListLoaded && isContentTaxonomyTagsLoaded && (
otherTaxonomies.map((data) => (
<div key={data.id}>
<ContentTagsCollapsible
contentId={contentId}
taxonomyAndTagsData={data}
stagedContentTags={stagedContentTags[data.id] || []}
collapsibleState={collapsibleStates[data.id] || false}
/>
<hr />
</div>
))
)}
</div>
)}
</Container>
</Container>
{renderFooter()}
{/* istanbul ignore next */
toastMessage && (
<Toast
show
onClose={closeToast}
>
{toastMessage}
</Toast>
)
}
</div>
</ContentTagsDrawerContext.Provider>
);
};
export default ContentTagsDrawer;

View File

@@ -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<any> {
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<any> {
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<any> {
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<any> {
return mockContentData.data;
}
mockContentData.data = {
displayName: 'Unit 1',
};
mockContentData.applyMock = () => jest.spyOn(api, 'getContentData').mockImplementation(mockContentData);

View File

@@ -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 */

View File

@@ -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: () => <div>Mocked ContentTagsDrawer</div>,
}));
/*
* This function is used to get the inner text of an element.
@@ -51,9 +56,8 @@ describe('<ComponentManagement />', () => {
initializeMocks();
mockLibraryBlockMetadata.applyMock();
render(<ComponentManagement usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} />);
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('<ComponentManagement />', () => {
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(<ComponentManagement usageKey={mockLibraryBlockMetadata.usageKeyForTags} />);
expect(await screen.findByText('Tags (6)')).toBeInTheDocument();
});
});

View File

@@ -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={(
<Stack gap={1} direction="horizontal">
<Icon src={Tag} />
{intl.formatMessage(messages.manageTabTagsTitle)}
{intl.formatMessage(messages.manageTabTagsTitle, { count: tagsCount })}
</Stack>
)}
className="border-0"
>
Tags placeholder
<ContentTagsDrawer
id={usageKey}
variant="component"
/>
</Collapsible>
)}
<Collapsible

View File

@@ -38,7 +38,7 @@ const messages = defineMessages({
},
manageTabTagsTitle: {
id: 'course-authoring.library-authoring.component.manage-tab.tags-title',
defaultMessage: 'Tags',
defaultMessage: 'Tags ({count})',
description: 'Title for the Tags container in the management tab',
},
manageTabCollectionsTitle: {

View File

@@ -1,4 +1,5 @@
/* istanbul ignore file */
import { mockContentTaxonomyTagsData } from '../../content-tags-drawer/data/api.mocks';
import { createAxiosError } from '../../testUtils';
import * as api from './api';
@@ -234,6 +235,7 @@ export async function mockLibraryBlockMetadata(usageKey: string): Promise<api.Li
throw createAxiosError({ code: 404, message: 'Not found.', path: api.getLibraryBlockMetadataUrl(usageKey) });
case thisMock.usageKeyNeverPublished: return thisMock.dataNeverPublished;
case thisMock.usageKeyPublished: return thisMock.dataPublished;
case thisMock.usageKeyForTags: return thisMock.dataPublished;
default: throw new Error(`No mock has been set up for usageKey "${usageKey}"`);
}
}
@@ -269,5 +271,6 @@ mockLibraryBlockMetadata.dataPublished = {
modified: '2024-06-21T13:54:21Z',
tagsCount: 0,
} satisfies api.LibraryBlockMetadata;
mockLibraryBlockMetadata.usageKeyForTags = mockContentTaxonomyTagsData.largeTagsId;
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
mockLibraryBlockMetadata.applyMock = () => jest.spyOn(api, 'getLibraryBlockMetadata').mockImplementation(mockLibraryBlockMetadata);

View File

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