diff --git a/src/content-tags-drawer/ContentTagsDrawer.jsx b/src/content-tags-drawer/ContentTagsDrawer.jsx
index 9429a65f3..c117f6fd2 100644
--- a/src/content-tags-drawer/ContentTagsDrawer.jsx
+++ b/src/content-tags-drawer/ContentTagsDrawer.jsx
@@ -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 = () => {
- closeContentTagsDrawer()} data-testid="drawer-close-button" />
+ onCloseDrawer()} data-testid="drawer-close-button" />
{intl.formatMessage(messages.headerSubtitle)}
{ isContentDataLoaded
? { contentData.displayName }
@@ -116,4 +133,14 @@ const ContentTagsDrawer = () => {
);
};
+ContentTagsDrawer.propTypes = {
+ id: PropTypes.string,
+ onClose: PropTypes.func,
+};
+
+ContentTagsDrawer.defaultProps = {
+ id: undefined,
+ onClose: undefined,
+};
+
export default ContentTagsDrawer;
diff --git a/src/content-tags-drawer/ContentTagsDrawer.test.jsx b/src/content-tags-drawer/ContentTagsDrawer.test.jsx
index b8fe58c3b..0f7f1815a 100644
--- a/src/content-tags-drawer/ContentTagsDrawer.test.jsx
+++ b/src/content-tags-drawer/ContentTagsDrawer.test.jsx
@@ -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) => (
-
+
);
@@ -77,6 +82,17 @@ describe(' ', () => {
});
});
+ it('shows content using params', async () => {
+ useContentData.mockReturnValue({
+ isSuccess: true,
+ data: {
+ displayName: 'Unit 1',
+ },
+ });
+ render( );
+ 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(' ', () => {
});
});
- 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( );
@@ -152,7 +168,17 @@ describe(' ', () => {
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( );
+
+ // 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( );
@@ -166,7 +192,7 @@ describe(' ', () => {
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( );
diff --git a/src/content-tags-drawer/data/apiHooks.jsx b/src/content-tags-drawer/data/apiHooks.jsx
index 1b95f6093..82e9a700c 100644
--- a/src/content-tags-drawer/data/apiHooks.jsx
+++ b/src/content-tags-drawer/data/apiHooks.jsx
@@ -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'] });
},
});
};
diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx
index 5710fd01c..07d11a9f0 100644
--- a/src/course-outline/CourseOutline.jsx
+++ b/src/course-outline/CourseOutline.jsx
@@ -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}
/>
))}
diff --git a/src/course-outline/CourseOutline.test.jsx b/src/course-outline/CourseOutline.test.jsx
index 3c2dc7649..55d260af3 100644
--- a/src/course-outline/CourseOutline.test.jsx
+++ b/src/course-outline/CourseOutline.test.jsx
@@ -78,6 +78,11 @@ jest.mock('@edx/frontend-platform/i18n', () => ({
}),
}));
+jest.mock('./data/apiHooks', () => () => ({
+ data: {},
+ isSuccess: true,
+}));
+
const RootWrapper = () => (
diff --git a/src/course-outline/__mocks__/contentTagsCount.js b/src/course-outline/__mocks__/contentTagsCount.js
new file mode 100644
index 000000000..b2fa2e8cd
--- /dev/null
+++ b/src/course-outline/__mocks__/contentTagsCount.js
@@ -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,
+};
diff --git a/src/course-outline/__mocks__/index.js b/src/course-outline/__mocks__/index.js
index 15c6504cb..c2e4997d3 100644
--- a/src/course-outline/__mocks__/index.js
+++ b/src/course-outline/__mocks__/index.js
@@ -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';
diff --git a/src/course-outline/card-header/CardHeader.jsx b/src/course-outline/card-header/CardHeader.jsx
index 1524b3fb1..cb43c5cd1 100644
--- a/src/course-outline/card-header/CardHeader.jsx
+++ b/src/course-outline/card-header/CardHeader.jsx
@@ -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) && (
)}
+ { tagsCount > 0 && }
{intl.formatMessage(messages.menuConfigure)}
+ {onClickManageTags && (
+
+ {intl.formatMessage(messages.menuManageTags)}
+
+ )}
+
{isVertical && enableCopyPasteUnits && (
{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;
diff --git a/src/course-outline/card-header/CardHeader.test.jsx b/src/course-outline/card-header/CardHeader.test.jsx
index 1a666b661..d6bd76920 100644
--- a/src/course-outline/card-header/CardHeader.test.jsx
+++ b/src/course-outline/card-header/CardHeader.test.jsx
@@ -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(' ', () => {
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(' ', () => {
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();
+ });
});
diff --git a/src/course-outline/card-header/messages.js b/src/course-outline/card-header/messages.js
index d9f250970..410443d69 100644
--- a/src/course-outline/card-header/messages.js
+++ b/src/course-outline/card-header/messages.js
@@ -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;
diff --git a/src/course-outline/data/api.js b/src/course-outline/data/api.js
index 3c2e03808..79e01ee51 100644
--- a/src/course-outline/data/api.js
+++ b/src/course-outline/data/api.js
@@ -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}
+*/
+export async function getTagsCount(contentPattern) {
+ if (contentPattern) {
+ const { data } = await getAuthenticatedHttpClient()
+ .get(getTagsCountApiUrl(contentPattern));
+
+ return data;
+ }
+ return null;
+}
diff --git a/src/course-outline/data/api.test.js b/src/course-outline/data/api.test.js
new file mode 100644
index 000000000..2c7ef9d7d
--- /dev/null
+++ b/src/course-outline/data/api.test.js
@@ -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);
+ });
+});
diff --git a/src/course-outline/data/apiHooks.jsx b/src/course-outline/data/apiHooks.jsx
new file mode 100644
index 000000000..ec1207fdd
--- /dev/null
+++ b/src/course-outline/data/apiHooks.jsx
@@ -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;
diff --git a/src/course-outline/data/apiHooks.test.jsx b/src/course-outline/data/apiHooks.test.jsx
new file mode 100644
index 000000000..0c9bf506b
--- /dev/null
+++ b/src/course-outline/data/apiHooks.test.jsx
@@ -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 });
+ });
+});
diff --git a/src/course-outline/unit-card/UnitCard.jsx b/src/course-outline/unit-card/UnitCard.jsx
index f2cb4ac3e..3c4f5ef8a 100644
--- a/src/course-outline/unit-card/UnitCard.jsx
+++ b/src/course-outline/unit-card/UnitCard.jsx
@@ -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 (
-
-
+
-
-
-
+ 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}
/>
+
+
+
-
-
+
+ setShowManageTags(false)}
+ >
+ setShowManageTags(false)}
+ />
+
+ >
);
};
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;
diff --git a/src/generic/tag-count/TagCount.scss b/src/generic/tag-count/TagCount.scss
new file mode 100644
index 000000000..2002e2e8e
--- /dev/null
+++ b/src/generic/tag-count/TagCount.scss
@@ -0,0 +1,3 @@
+.generic-tag-count.zero-count {
+ opacity: .4;
+}
diff --git a/src/generic/tag-count/TagCount.test.jsx b/src/generic/tag-count/TagCount.test.jsx
new file mode 100644
index 000000000..bb88d44a5
--- /dev/null
+++ b/src/generic/tag-count/TagCount.test.jsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import TagCount from '.';
+
+describe('', () => {
+ it('should render the component', () => {
+ render( );
+ expect(screen.getByText('17')).toBeInTheDocument();
+ });
+
+ it('should render the component with zero', () => {
+ render( );
+ expect(screen.getByText('0')).toBeInTheDocument();
+ });
+
+ it('should render a button with onClick', () => {
+ render( {}} />);
+ expect(screen.getByRole('button', {
+ name: /17/i,
+ }));
+ });
+});
diff --git a/src/generic/tag-count/index.jsx b/src/generic/tag-count/index.jsx
new file mode 100644
index 000000000..bb6dada9d
--- /dev/null
+++ b/src/generic/tag-count/index.jsx
@@ -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 = () => (
+ <>
+
+ {count}
+ >
+ );
+
+ return (
+
+ { onClick ? (
+
+ {renderContent()}
+
+ )
+ : renderContent()}
+
+ );
+};
+
+TagCount.defaultProps = {
+ onClick: undefined,
+};
+
+TagCount.propTypes = {
+ count: PropTypes.number.isRequired,
+ onClick: PropTypes.func,
+};
+
+export default TagCount;