Skip to content

Commit

Permalink
[UU-58] Implement tagging & taxonomy feature in outline (#855)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ChrisChV authored Mar 8, 2024
1 parent 642b4e4 commit c39b52a
Show file tree
Hide file tree
Showing 18 changed files with 389 additions and 66 deletions.
43 changes: 35 additions & 8 deletions src/content-tags-drawer/ContentTagsDrawer.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// @ts-check
import React, { useMemo, useEffect } from 'react';
import PropTypes from 'prop-types';
import {
Container,
CloseButton,
Expand All @@ -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);

Expand All @@ -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);
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -116,4 +133,14 @@ const ContentTagsDrawer = () => {
);
};

ContentTagsDrawer.propTypes = {
id: PropTypes.string,
onClose: PropTypes.func,
};

ContentTagsDrawer.defaultProps = {
id: undefined,
onClose: undefined,
};

export default ContentTagsDrawer;
40 changes: 33 additions & 7 deletions src/content-tags-drawer/ContentTagsDrawer.test.jsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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,
}),
}));

Expand All @@ -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>
);

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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 />);
Expand All @@ -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 />);
Expand All @@ -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 />);
Expand Down
4 changes: 3 additions & 1 deletion src/content-tags-drawer/data/apiHooks.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'] });
},
});
};
25 changes: 24 additions & 1 deletion src/course-outline/CourseOutline.jsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -405,6 +427,7 @@ const CourseOutline = ({ courseId }) => {
)}
onCopyToClipboardClick={handleCopyToClipboardClick}
discussionsSettings={discussionsSettings}
tagsCount={isUnitsTagCountsLoaded ? unitsTagCounts[unit.id] : 0}
/>
))}
</DraggableList>
Expand Down
5 changes: 5 additions & 0 deletions src/course-outline/CourseOutline.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand Down
8 changes: 8 additions & 0 deletions src/course-outline/__mocks__/contentTagsCount.js
Original file line number Diff line number Diff line change
@@ -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,
};
1 change: 1 addition & 0 deletions src/course-outline/__mocks__/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
17 changes: 17 additions & 0 deletions src/course-outline/card-header/CardHeader.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -27,6 +28,7 @@ const CardHeader = ({
hasChanges,
onClickPublish,
onClickConfigure,
onClickManageTags,
onClickMenuButton,
onClickEdit,
isFormOpen,
Expand All @@ -48,6 +50,7 @@ const CardHeader = ({
discussionEnabled,
discussionsSettings,
parentInfo,
tagsCount,
}) => {
const intl = useIntl();
const [searchParams] = useSearchParams();
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)}
Expand Down Expand Up @@ -218,6 +231,8 @@ CardHeader.defaultProps = {
discussionEnabled: false,
discussionsSettings: {},
parentInfo: {},
onClickManageTags: null,
tagsCount: undefined,
};

CardHeader.propTypes = {
Expand All @@ -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,
Expand Down Expand Up @@ -261,6 +277,7 @@ CardHeader.propTypes = {
isTimeLimited: PropTypes.bool,
graded: PropTypes.bool,
}),
tagsCount: PropTypes.number,
};

export default CardHeader;
30 changes: 29 additions & 1 deletion src/course-outline/card-header/CardHeader.test.jsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 = {
Expand All @@ -28,6 +29,7 @@ const cardHeaderProps = {
onClickMenuButton: onClickMenuButtonMock,
onClickPublish: onClickPublishMock,
onClickEdit: onClickEditMock,
onClickManageTags: onClickManageTagsMock,
isFormOpen: false,
onEditSubmit: jest.fn(),
closeForm: closeFormMock,
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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();
});
});
Loading

0 comments on commit c39b52a

Please sign in to comment.