[UU-58] Implement tagging & taxonomy feature in outline (#855)
* feat: TagCount component * feat: Update ContentTagsDrawer to use it in the MFE * feat: Manage tags menu added on units * feat: Tag count added on unit * feat: Add button feat to Tag count * test: Course Outline api tests * test: Ignore lines that can not be tested * style: Comment added on ContentTagsDrawer * style: Nits on CardHeader
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
// @ts-check
|
||||
import React, { useMemo, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Container,
|
||||
CloseButton,
|
||||
@@ -20,9 +21,22 @@ import Loading from '../generic/Loading';
|
||||
/** @typedef {import("../taxonomy/data/types.mjs").TaxonomyData} TaxonomyData */
|
||||
/** @typedef {import("./data/types.mjs").Tag} ContentTagData */
|
||||
|
||||
const ContentTagsDrawer = () => {
|
||||
/**
|
||||
* 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.
|
||||
* - 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();
|
||||
const { contentId } = /** @type {{contentId: string}} */(useParams());
|
||||
const params = useParams();
|
||||
let contentId = id;
|
||||
|
||||
if (contentId === undefined) {
|
||||
contentId = params.contentId;
|
||||
}
|
||||
|
||||
const org = extractOrgFromContentId(contentId);
|
||||
|
||||
@@ -39,17 +53,20 @@ const ContentTagsDrawer = () => {
|
||||
} = useContentTaxonomyTagsData(contentId);
|
||||
const { taxonomyListData, isTaxonomyListLoaded } = useTaxonomyListData();
|
||||
|
||||
const closeContentTagsDrawer = () => {
|
||||
// "*" allows communication with any origin
|
||||
window.parent.postMessage('closeManageTagsDrawer', '*');
|
||||
};
|
||||
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) {
|
||||
closeContentTagsDrawer();
|
||||
onCloseDrawer();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
@@ -86,7 +103,7 @@ const ContentTagsDrawer = () => {
|
||||
|
||||
<div className="mt-1">
|
||||
<Container size="xl">
|
||||
<CloseButton onClick={() => closeContentTagsDrawer()} data-testid="drawer-close-button" />
|
||||
<CloseButton onClick={() => onCloseDrawer()} data-testid="drawer-close-button" />
|
||||
<span>{intl.formatMessage(messages.headerSubtitle)}</span>
|
||||
{ isContentDataLoaded
|
||||
? <h3>{ contentData.displayName }</h3>
|
||||
@@ -116,4 +133,14 @@ const ContentTagsDrawer = () => {
|
||||
);
|
||||
};
|
||||
|
||||
ContentTagsDrawer.propTypes = {
|
||||
id: PropTypes.string,
|
||||
onClose: PropTypes.func,
|
||||
};
|
||||
|
||||
ContentTagsDrawer.defaultProps = {
|
||||
id: undefined,
|
||||
onClose: undefined,
|
||||
};
|
||||
|
||||
export default ContentTagsDrawer;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { act, render, fireEvent } from '@testing-library/react';
|
||||
import {
|
||||
act, render, fireEvent, screen,
|
||||
} from '@testing-library/react';
|
||||
|
||||
import ContentTagsDrawer from './ContentTagsDrawer';
|
||||
import {
|
||||
@@ -9,10 +11,13 @@ import {
|
||||
} from './data/apiHooks';
|
||||
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks';
|
||||
|
||||
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab';
|
||||
const mockOnClose = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({
|
||||
contentId: 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab',
|
||||
contentId,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -35,9 +40,9 @@ jest.mock('../taxonomy/data/apiHooks', () => ({
|
||||
useIsTaxonomyListDataLoaded: jest.fn(),
|
||||
}));
|
||||
|
||||
const RootWrapper = () => (
|
||||
const RootWrapper = (params) => (
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<ContentTagsDrawer />
|
||||
<ContentTagsDrawer {...params} />
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
@@ -77,6 +82,17 @@ describe('<ContentTagsDrawer />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows content using params', async () => {
|
||||
useContentData.mockReturnValue({
|
||||
isSuccess: true,
|
||||
data: {
|
||||
displayName: 'Unit 1',
|
||||
},
|
||||
});
|
||||
render(<RootWrapper id={contentId} />);
|
||||
expect(screen.getByText('Unit 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the taxonomies data including tag numbers after the query is complete', async () => {
|
||||
useIsTaxonomyListDataLoaded.mockReturnValue(true);
|
||||
useContentTaxonomyTagsData.mockReturnValue({
|
||||
@@ -138,7 +154,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should call closeContentTagsDrawer when CloseButton is clicked', async () => {
|
||||
it('should call closeManageTagsDrawer when CloseButton is clicked', async () => {
|
||||
const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
|
||||
|
||||
const { getByTestId } = render(<RootWrapper />);
|
||||
@@ -152,7 +168,17 @@ describe('<ContentTagsDrawer />', () => {
|
||||
postMessageSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should call closeContentTagsDrawer when Escape key is pressed and no selectable box is active', () => {
|
||||
it('should call onClose param when CloseButton is clicked', async () => {
|
||||
render(<RootWrapper onClose={mockOnClose} />);
|
||||
|
||||
// Find the CloseButton element by its test ID and trigger a click event
|
||||
const closeButton = screen.getByTestId('drawer-close-button');
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 />);
|
||||
@@ -166,7 +192,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
postMessageSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not call closeContentTagsDrawer when Escape key is pressed and a selectable box is active', () => {
|
||||
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 />);
|
||||
|
||||
@@ -135,8 +135,10 @@ export const useContentTaxonomyTagsUpdater = (contentId, taxonomyId) => {
|
||||
* >}
|
||||
*/
|
||||
mutationFn: ({ tags }) => updateContentTaxonomyTags(contentId, taxonomyId, tags),
|
||||
onSettled: () => {
|
||||
onSettled: /* istanbul ignore next */ () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTags', contentId] });
|
||||
/// Invalidate query with pattern on course outline
|
||||
queryClient.invalidateQueries({ queryKey: ['unitTagsCount'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
@@ -43,6 +43,7 @@ import ConfigureModal from './configure-modal/ConfigureModal';
|
||||
import PageAlerts from './page-alerts/PageAlerts';
|
||||
import { useCourseOutline } from './hooks';
|
||||
import messages from './messages';
|
||||
import useUnitTagsCount from './data/apiHooks';
|
||||
|
||||
const CourseOutline = ({ courseId }) => {
|
||||
const intl = useIntl();
|
||||
@@ -162,6 +163,27 @@ const CourseOutline = ({ courseId }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const unitsIdPattern = useMemo(() => {
|
||||
let pattern = '';
|
||||
sections.forEach((section) => {
|
||||
section.childInfo.children.forEach((subsection) => {
|
||||
subsection.childInfo.children.forEach((unit) => {
|
||||
if (pattern !== '') {
|
||||
pattern += `,${unit.id}`;
|
||||
} else {
|
||||
pattern += unit.id;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return pattern;
|
||||
}, [sections]);
|
||||
|
||||
const {
|
||||
data: unitsTagCounts,
|
||||
isSuccess: isUnitsTagCountsLoaded,
|
||||
} = useUnitTagsCount(unitsIdPattern);
|
||||
|
||||
/**
|
||||
* Check if item can be moved by given step.
|
||||
* Inner function returns false if the new index after moving by given step
|
||||
@@ -405,6 +427,7 @@ const CourseOutline = ({ courseId }) => {
|
||||
)}
|
||||
onCopyToClipboardClick={handleCopyToClipboardClick}
|
||||
discussionsSettings={discussionsSettings}
|
||||
tagsCount={isUnitsTagCountsLoaded ? unitsTagCounts[unit.id] : 0}
|
||||
/>
|
||||
))}
|
||||
</DraggableList>
|
||||
|
||||
@@ -78,6 +78,11 @@ jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('./data/apiHooks', () => () => ({
|
||||
data: {},
|
||||
isSuccess: true,
|
||||
}));
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
|
||||
8
src/course-outline/__mocks__/contentTagsCount.js
Normal file
8
src/course-outline/__mocks__/contentTagsCount.js
Normal file
@@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb01': 10,
|
||||
'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb02': 11,
|
||||
'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb03': 12,
|
||||
'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb04': 13,
|
||||
'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb05': 14,
|
||||
'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb06': 15,
|
||||
};
|
||||
@@ -4,3 +4,4 @@ export { default as courseBestPracticesMock } from './courseBestPractices';
|
||||
export { default as courseLaunchMock } from './courseLaunch';
|
||||
export { default as courseSectionMock } from './courseSection';
|
||||
export { default as courseSubsectionMock } from './courseSubsection';
|
||||
export { default as contentTagsCountMock } from './contentTagsCount';
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ITEM_BADGE_STATUS } from '../constants';
|
||||
import { scrollToElement } from '../utils';
|
||||
import CardStatus from './CardStatus';
|
||||
import messages from './messages';
|
||||
import TagCount from '../../generic/tag-count';
|
||||
|
||||
const CardHeader = ({
|
||||
title,
|
||||
@@ -27,6 +28,7 @@ const CardHeader = ({
|
||||
hasChanges,
|
||||
onClickPublish,
|
||||
onClickConfigure,
|
||||
onClickManageTags,
|
||||
onClickMenuButton,
|
||||
onClickEdit,
|
||||
isFormOpen,
|
||||
@@ -48,6 +50,7 @@ const CardHeader = ({
|
||||
discussionEnabled,
|
||||
discussionsSettings,
|
||||
parentInfo,
|
||||
tagsCount,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -127,6 +130,7 @@ const CardHeader = ({
|
||||
{(isVertical || isSequential) && (
|
||||
<CardStatus status={status} showDiscussionsEnabledBadge={showDiscussionsEnabledBadge} />
|
||||
)}
|
||||
{ tagsCount > 0 && <TagCount count={tagsCount} onClick={onClickManageTags} /> }
|
||||
<Dropdown data-testid={`${namePrefix}-card-header__menu`} onClick={onClickMenuButton}>
|
||||
<Dropdown.Toggle
|
||||
className="item-card-header__menu"
|
||||
@@ -162,6 +166,15 @@ const CardHeader = ({
|
||||
>
|
||||
{intl.formatMessage(messages.menuConfigure)}
|
||||
</Dropdown.Item>
|
||||
{onClickManageTags && (
|
||||
<Dropdown.Item
|
||||
data-testid={`${namePrefix}-card-header__menu-manage-tags-button`}
|
||||
onClick={onClickManageTags}
|
||||
>
|
||||
{intl.formatMessage(messages.menuManageTags)}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
|
||||
{isVertical && enableCopyPasteUnits && (
|
||||
<Dropdown.Item onClick={onClickCopy}>
|
||||
{intl.formatMessage(messages.menuCopy)}
|
||||
@@ -218,6 +231,8 @@ CardHeader.defaultProps = {
|
||||
discussionEnabled: false,
|
||||
discussionsSettings: {},
|
||||
parentInfo: {},
|
||||
onClickManageTags: null,
|
||||
tagsCount: undefined,
|
||||
};
|
||||
|
||||
CardHeader.propTypes = {
|
||||
@@ -227,6 +242,7 @@ CardHeader.propTypes = {
|
||||
hasChanges: PropTypes.bool.isRequired,
|
||||
onClickPublish: PropTypes.func.isRequired,
|
||||
onClickConfigure: PropTypes.func.isRequired,
|
||||
onClickManageTags: PropTypes.func,
|
||||
onClickMenuButton: PropTypes.func.isRequired,
|
||||
onClickEdit: PropTypes.func.isRequired,
|
||||
isFormOpen: PropTypes.bool.isRequired,
|
||||
@@ -261,6 +277,7 @@ CardHeader.propTypes = {
|
||||
isTimeLimited: PropTypes.bool,
|
||||
graded: PropTypes.bool,
|
||||
}),
|
||||
tagsCount: PropTypes.number,
|
||||
};
|
||||
|
||||
export default CardHeader;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import {
|
||||
act, render, fireEvent, waitFor,
|
||||
act, render, fireEvent, waitFor, screen,
|
||||
} from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
@@ -18,6 +18,7 @@ const onClickDuplicateMock = jest.fn();
|
||||
const onClickConfigureMock = jest.fn();
|
||||
const onClickMoveUpMock = jest.fn();
|
||||
const onClickMoveDownMock = jest.fn();
|
||||
const onClickManageTagsMock = jest.fn();
|
||||
const closeFormMock = jest.fn();
|
||||
|
||||
const cardHeaderProps = {
|
||||
@@ -28,6 +29,7 @@ const cardHeaderProps = {
|
||||
onClickMenuButton: onClickMenuButtonMock,
|
||||
onClickPublish: onClickPublishMock,
|
||||
onClickEdit: onClickEditMock,
|
||||
onClickManageTags: onClickManageTagsMock,
|
||||
isFormOpen: false,
|
||||
onEditSubmit: jest.fn(),
|
||||
closeForm: closeFormMock,
|
||||
@@ -168,6 +170,16 @@ describe('<CardHeader />', () => {
|
||||
expect(onClickPublishMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClickManageTags when the menu is clicked', async () => {
|
||||
renderComponent();
|
||||
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const manageTagsMenuItem = await screen.findByText(messages.menuManageTags.defaultMessage);
|
||||
await act(async () => fireEvent.click(manageTagsMenuItem));
|
||||
expect(onClickManageTagsMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClickEdit when the button is clicked', async () => {
|
||||
const { findByTestId } = renderComponent();
|
||||
|
||||
@@ -251,4 +263,20 @@ describe('<CardHeader />', () => {
|
||||
|
||||
expect(queryByText(messages.discussionEnabledBadgeText.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render tag count if is not zero', () => {
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
tagsCount: 17,
|
||||
});
|
||||
expect(screen.getByText('17')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render tag count if is zero', () => {
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
tagsCount: 0,
|
||||
});
|
||||
expect(screen.queryByText('0')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,6 +73,10 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.course-outline.card.badge.discussionEnabled',
|
||||
defaultMessage: 'Discussions enabled',
|
||||
},
|
||||
menuManageTags: {
|
||||
id: 'course-authoring.course-outline.card.menu.manageTags',
|
||||
defaultMessage: 'Manage tags',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -29,6 +29,7 @@ export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`;
|
||||
export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`;
|
||||
export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`;
|
||||
export const getClipboardUrl = () => `${getApiBaseUrl()}/api/content-staging/v1/clipboard/`;
|
||||
export const getTagsCountApiUrl = (contentPattern) => new URL(`api/content_tagging/v1/object_tag_counts/${contentPattern}/?count_implicit`, getApiBaseUrl()).href;
|
||||
|
||||
/**
|
||||
* @typedef {Object} courseOutline
|
||||
@@ -472,3 +473,18 @@ export async function dismissNotification(url) {
|
||||
await getAuthenticatedHttpClient()
|
||||
.delete(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the tags count of multiple content by id separated by commas.
|
||||
* @param {string} contentPattern
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getTagsCount(contentPattern) {
|
||||
if (contentPattern) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getTagsCountApiUrl(contentPattern));
|
||||
|
||||
return data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
40
src/course-outline/data/api.test.js
Normal file
40
src/course-outline/data/api.test.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { contentTagsCountMock } from '../__mocks__';
|
||||
import { getTagsCountApiUrl, getTagsCount } from './api';
|
||||
|
||||
let axiosMock;
|
||||
|
||||
describe('course outline api calls', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should get tags count', async () => {
|
||||
const pattern = 'this,is,a,pattern';
|
||||
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb06';
|
||||
axiosMock.onGet().reply(200, contentTagsCountMock);
|
||||
const result = await getTagsCount(pattern);
|
||||
expect(axiosMock.history.get[0].url).toEqual(getTagsCountApiUrl(pattern));
|
||||
expect(result).toEqual(contentTagsCountMock);
|
||||
expect(contentTagsCountMock[contentId]).toEqual(15);
|
||||
});
|
||||
|
||||
it('should get null on empty pattenr', async () => {
|
||||
const result = await getTagsCount('');
|
||||
expect(result).toEqual(null);
|
||||
});
|
||||
});
|
||||
16
src/course-outline/data/apiHooks.jsx
Normal file
16
src/course-outline/data/apiHooks.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
// @ts-check
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getTagsCount } from './api';
|
||||
|
||||
/**
|
||||
* Builds the query to get tags count of a group of units.
|
||||
* @param {string} contentPattern The IDs of units separated by commas.
|
||||
*/
|
||||
const useUnitTagsCount = (contentPattern) => (
|
||||
useQuery({
|
||||
queryKey: ['unitTagsCount', contentPattern],
|
||||
queryFn: /* istanbul ignore next */ () => getTagsCount(contentPattern),
|
||||
})
|
||||
);
|
||||
|
||||
export default useUnitTagsCount;
|
||||
28
src/course-outline/data/apiHooks.test.jsx
Normal file
28
src/course-outline/data/apiHooks.test.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import useUnitTagsCount from './apiHooks';
|
||||
|
||||
jest.mock('@tanstack/react-query', () => ({
|
||||
useQuery: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
getTagsCount: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('useUnitTagsCount', () => {
|
||||
it('should return success response', () => {
|
||||
useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
|
||||
const pattern = '123';
|
||||
const result = useUnitTagsCount(pattern);
|
||||
|
||||
expect(result).toEqual({ isSuccess: true, data: 'data' });
|
||||
});
|
||||
|
||||
it('should return failure response', () => {
|
||||
useQuery.mockReturnValueOnce({ isSuccess: false });
|
||||
const pattern = '123';
|
||||
const result = useUnitTagsCount(pattern);
|
||||
|
||||
expect(result).toEqual({ isSuccess: false });
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
import { useToggle, Sheet } from '@openedx/paragon';
|
||||
|
||||
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
@@ -10,6 +10,7 @@ import ConditionalSortableElement from '../drag-helper/ConditionalSortableElemen
|
||||
import TitleLink from '../card-header/TitleLink';
|
||||
import XBlockStatus from '../xblock-status/XBlockStatus';
|
||||
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
|
||||
import { ContentTagsDrawer } from '../../content-tags-drawer';
|
||||
|
||||
const UnitCard = ({
|
||||
unit,
|
||||
@@ -29,11 +30,13 @@ const UnitCard = ({
|
||||
onOrderChange,
|
||||
onCopyToClipboardClick,
|
||||
discussionsSettings,
|
||||
tagsCount,
|
||||
}) => {
|
||||
const currentRef = useRef(null);
|
||||
const dispatch = useDispatch();
|
||||
const [isFormOpen, openForm, closeForm] = useToggle(false);
|
||||
const namePrefix = 'unit';
|
||||
const [showManageTags, setShowManageTags] = useState(false);
|
||||
|
||||
const {
|
||||
id,
|
||||
@@ -122,60 +125,75 @@ const UnitCard = ({
|
||||
const isDraggable = actions.draggable && (actions.allowMoveUp || actions.allowMoveDown);
|
||||
|
||||
return (
|
||||
<ConditionalSortableElement
|
||||
id={id}
|
||||
key={id}
|
||||
draggable={isDraggable}
|
||||
componentStyle={{
|
||||
background: '#fdfdfd',
|
||||
...borderStyle,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="unit-card"
|
||||
data-testid="unit-card"
|
||||
ref={currentRef}
|
||||
<>
|
||||
<ConditionalSortableElement
|
||||
id={id}
|
||||
key={id}
|
||||
draggable={isDraggable}
|
||||
componentStyle={{
|
||||
background: '#fdfdfd',
|
||||
...borderStyle,
|
||||
}}
|
||||
>
|
||||
<CardHeader
|
||||
title={displayName}
|
||||
status={unitStatus}
|
||||
hasChanges={hasChanges}
|
||||
onClickMenuButton={handleClickMenuButton}
|
||||
onClickPublish={onOpenPublishModal}
|
||||
onClickConfigure={onOpenConfigureModal}
|
||||
onClickEdit={openForm}
|
||||
onClickDelete={onOpenDeleteModal}
|
||||
onClickMoveUp={handleUnitMoveUp}
|
||||
onClickMoveDown={handleUnitMoveDown}
|
||||
isFormOpen={isFormOpen}
|
||||
closeForm={closeForm}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
|
||||
onClickDuplicate={onDuplicateSubmit}
|
||||
titleComponent={titleComponent}
|
||||
namePrefix={namePrefix}
|
||||
actions={actions}
|
||||
isVertical
|
||||
enableCopyPasteUnits={enableCopyPasteUnits}
|
||||
onClickCopy={handleCopyClick}
|
||||
discussionEnabled={discussionEnabled}
|
||||
discussionsSettings={discussionsSettings}
|
||||
parentInfo={parentInfo}
|
||||
/>
|
||||
<div className="unit-card__content item-children" data-testid="unit-card__content">
|
||||
<XBlockStatus
|
||||
isSelfPaced={isSelfPaced}
|
||||
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
|
||||
blockData={unit}
|
||||
<div
|
||||
className="unit-card"
|
||||
data-testid="unit-card"
|
||||
ref={currentRef}
|
||||
>
|
||||
<CardHeader
|
||||
title={displayName}
|
||||
status={unitStatus}
|
||||
hasChanges={hasChanges}
|
||||
onClickMenuButton={handleClickMenuButton}
|
||||
onClickPublish={onOpenPublishModal}
|
||||
onClickConfigure={onOpenConfigureModal}
|
||||
onClickManageTags={/* istanbul ignore next */ () => setShowManageTags(true)}
|
||||
onClickEdit={openForm}
|
||||
onClickDelete={onOpenDeleteModal}
|
||||
onClickMoveUp={handleUnitMoveUp}
|
||||
onClickMoveDown={handleUnitMoveDown}
|
||||
isFormOpen={isFormOpen}
|
||||
closeForm={closeForm}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
|
||||
onClickDuplicate={onDuplicateSubmit}
|
||||
titleComponent={titleComponent}
|
||||
namePrefix={namePrefix}
|
||||
actions={actions}
|
||||
isVertical
|
||||
enableCopyPasteUnits={enableCopyPasteUnits}
|
||||
onClickCopy={handleCopyClick}
|
||||
discussionEnabled={discussionEnabled}
|
||||
discussionsSettings={discussionsSettings}
|
||||
parentInfo={parentInfo}
|
||||
tagsCount={tagsCount}
|
||||
/>
|
||||
<div className="unit-card__content item-children" data-testid="unit-card__content">
|
||||
<XBlockStatus
|
||||
isSelfPaced={isSelfPaced}
|
||||
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
|
||||
blockData={unit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ConditionalSortableElement>
|
||||
</ConditionalSortableElement>
|
||||
<Sheet
|
||||
position="right"
|
||||
show={showManageTags}
|
||||
onClose={/* istanbul ignore next */ () => setShowManageTags(false)}
|
||||
>
|
||||
<ContentTagsDrawer
|
||||
id={id}
|
||||
onClose={/* istanbul ignore next */ () => setShowManageTags(false)}
|
||||
/>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
UnitCard.defaultProps = {
|
||||
discussionsSettings: {},
|
||||
tagsCount: undefined,
|
||||
};
|
||||
|
||||
UnitCard.propTypes = {
|
||||
@@ -231,6 +249,7 @@ UnitCard.propTypes = {
|
||||
providerType: PropTypes.string,
|
||||
enableGradedUnits: PropTypes.bool,
|
||||
}),
|
||||
tagsCount: PropTypes.number,
|
||||
};
|
||||
|
||||
export default UnitCard;
|
||||
|
||||
3
src/generic/tag-count/TagCount.scss
Normal file
3
src/generic/tag-count/TagCount.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.generic-tag-count.zero-count {
|
||||
opacity: .4;
|
||||
}
|
||||
22
src/generic/tag-count/TagCount.test.jsx
Normal file
22
src/generic/tag-count/TagCount.test.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import TagCount from '.';
|
||||
|
||||
describe('<TagCount>', () => {
|
||||
it('should render the component', () => {
|
||||
render(<TagCount count={17} />);
|
||||
expect(screen.getByText('17')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the component with zero', () => {
|
||||
render(<TagCount count={0} />);
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render a button with onClick', () => {
|
||||
render(<TagCount count={17} onClick={() => {}} />);
|
||||
expect(screen.getByRole('button', {
|
||||
name: /17/i,
|
||||
}));
|
||||
});
|
||||
});
|
||||
38
src/generic/tag-count/index.jsx
Normal file
38
src/generic/tag-count/index.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { Icon, Button } from '@openedx/paragon';
|
||||
import { Tag } from '@openedx/paragon/icons';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const TagCount = ({ count, onClick }) => {
|
||||
const renderContent = () => (
|
||||
<>
|
||||
<Icon className="mr-1 pt-1" src={Tag} />
|
||||
{count}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={
|
||||
classNames('generic-tag-count d-flex', { 'zero-count': count === 0 })
|
||||
}
|
||||
>
|
||||
{ onClick ? (
|
||||
<Button variant="tertiary" onClick={onClick}>
|
||||
{renderContent()}
|
||||
</Button>
|
||||
)
|
||||
: renderContent()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
TagCount.defaultProps = {
|
||||
onClick: undefined,
|
||||
};
|
||||
|
||||
TagCount.propTypes = {
|
||||
count: PropTypes.number.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
export default TagCount;
|
||||
Reference in New Issue
Block a user