feat: Add tags to manage sidebar of library components (#1299)
This commit is contained in:
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
390
src/content-tags-drawer/ContentTagsDrawer.tsx
Normal file
390
src/content-tags-drawer/ContentTagsDrawer.tsx
Normal 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;
|
||||
378
src/content-tags-drawer/data/api.mocks.ts
Normal file
378
src/content-tags-drawer/data/api.mocks.ts
Normal 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);
|
||||
@@ -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 */
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"]`
|
||||
|
||||
Reference in New Issue
Block a user