[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:
Chris Chávez
2024-03-08 10:00:51 -05:00
committed by GitHub
parent 642b4e4052
commit c39b52a6bf
18 changed files with 389 additions and 66 deletions

View File

@@ -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;

View File

@@ -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 />);

View File

@@ -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'] });
},
});
};

View File

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

View File

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

View 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,
};

View File

@@ -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';

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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;
}

View 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);
});
});

View 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;

View 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 });
});
});

View File

@@ -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;

View File

@@ -0,0 +1,3 @@
.generic-tag-count.zero-count {
opacity: .4;
}

View 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,
}));
});
});

View 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;