Skip to content

Commit

Permalink
[FC-0036] Tags Sidebar (#852)
Browse files Browse the repository at this point in the history
* refactor: Unit sidebar to create the TagsSidebar

* feat: Structure of TagsSidebar and TagsTree

* feat: Adding styles to the TagsTree

* feat: TagsSidebarHeader created

* feat: Add count on TagsSidebarHeader

* test: Tests for new components added

* style: Update tags count with opacity when the count is zero

* refactor: Extract tag count component as generic

* refactor: Transform Sidebar to a wrapper component

---------

Co-authored-by: Rômulo Penido <[email protected]>
  • Loading branch information
ChrisChV and rpenido authored Mar 15, 2024
1 parent 6ae9cda commit d57ecc6
Show file tree
Hide file tree
Showing 29 changed files with 667 additions and 101 deletions.
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/content-tags-drawer/ContentTagsDrawer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,18 @@ import Loading from '../generic/Loading';
* 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 this when the iframe is no longer used on edx-platform
const params = useParams();
let contentId = id;

if (contentId === undefined) {
// TODO: We can delete this when the iframe is no longer used on edx-platform
contentId = params.contentId;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b': 20,
};
35 changes: 35 additions & 0 deletions src/content-tags-drawer/__mocks__/contentTaxonomyTagsTreeMock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
module.exports = {
'hierarchical taxonomy tag 1': {
children: {
'hierarchical taxonomy tag 1.7': {
children: {
'hierarchical taxonomy tag 1.7.59': {
children: {},
},
},
},
},
},
'hierarchical taxonomy tag 2': {
children: {
'hierarchical taxonomy tag 2.13': {
children: {
'hierarchical taxonomy tag 2.13.46': {
children: {},
},
},
},
},
},
'hierarchical taxonomy tag 3': {
children: {
'hierarchical taxonomy tag 3.4': {
children: {
'hierarchical taxonomy tag 3.4.50': {
children: {},
},
},
},
},
},
};
2 changes: 2 additions & 0 deletions src/content-tags-drawer/__mocks__/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ export { default as taxonomyTagsMock } from './taxonomyTagsMock';
export { default as contentTaxonomyTagsMock } from './contentTaxonomyTagsMock';
export { default as contentDataMock } from './contentDataMock';
export { default as updateContentTaxonomyTagsMock } from './updateContentTaxonomyTagsMock';
export { default as contentTaxonomyTagsCountMock } from './contentTaxonomyTagsCountMock';
export { default as contentTaxonomyTagsTreeMock } from './contentTaxonomyTagsTreeMock';
14 changes: 14 additions & 0 deletions src/content-tags-drawer/data/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const getTaxonomyTagsApiUrl = (taxonomyId, options = {}) => {
export const getContentTaxonomyTagsApiUrl = (contentId) => new URL(`api/content_tagging/v1/object_tags/${contentId}/`, getApiBaseUrl()).href;
export const getXBlockContentDataApiURL = (contentId) => new URL(`/xblock/outline/${contentId}`, getApiBaseUrl()).href;
export const getLibraryContentDataApiUrl = (contentId) => new URL(`/api/libraries/v2/blocks/${contentId}/`, getApiBaseUrl()).href;
export const getContentTaxonomyTagsCountApiUrl = (contentId) => new URL(`api/content_tagging/v1/object_tag_counts/${contentId}/?count_implicit`, getApiBaseUrl()).href;

/**
* Get all tags that belong to taxonomy.
Expand All @@ -54,6 +55,19 @@ export async function getContentTaxonomyTagsData(contentId) {
return camelCaseObject(data[contentId]);
}

/**
* Get the count of tags that are applied to the content object
* @param {string} contentId The id of the content object to fetch the count of the applied tags for
* @returns {Promise<number>}
*/
export async function getContentTaxonomyTagsCount(contentId) {
const { data } = await getAuthenticatedHttpClient().get(getContentTaxonomyTagsCountApiUrl(contentId));
if (contentId in data) {
return camelCaseObject(data[contentId]);
}
return 0;
}

/**
* Fetch meta data (eg: display_name) about the content object (unit/compoenent)
* @param {string} contentId The id of the content object (unit/component)
Expand Down
21 changes: 21 additions & 0 deletions src/content-tags-drawer/data/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
taxonomyTagsMock,
contentTaxonomyTagsMock,
contentTaxonomyTagsCountMock,
contentDataMock,
updateContentTaxonomyTagsMock,
} from '../__mocks__';
Expand All @@ -19,6 +20,8 @@ import {
getContentTaxonomyTagsData,
getContentData,
updateContentTaxonomyTags,
getContentTaxonomyTagsCountApiUrl,
getContentTaxonomyTagsCount,
} from './api';

let axiosMock;
Expand Down Expand Up @@ -88,6 +91,24 @@ describe('content tags drawer api calls', () => {
expect(result).toEqual(contentTaxonomyTagsMock[contentId]);
});

it('should get content taxonomy tags count', async () => {
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
axiosMock.onGet(getContentTaxonomyTagsCountApiUrl(contentId)).reply(200, contentTaxonomyTagsCountMock);
const result = await getContentTaxonomyTagsCount(contentId);

expect(axiosMock.history.get[0].url).toEqual(getContentTaxonomyTagsCountApiUrl(contentId));
expect(result).toEqual(contentTaxonomyTagsCountMock[contentId]);
});

it('should get content taxonomy tags count as zero', async () => {
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
axiosMock.onGet(getContentTaxonomyTagsCountApiUrl(contentId)).reply(200, {});
const result = await getContentTaxonomyTagsCount(contentId);

expect(axiosMock.history.get[0].url).toEqual(getContentTaxonomyTagsCountApiUrl(contentId));
expect(result).toEqual(0);
});

it('should get content data for course component', async () => {
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
axiosMock.onGet(getXBlockContentDataApiURL(contentId)).reply(200, contentDataMock);
Expand Down
13 changes: 13 additions & 0 deletions src/content-tags-drawer/data/apiHooks.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
getContentTaxonomyTagsData,
getContentData,
updateContentTaxonomyTags,
getContentTaxonomyTagsCount,
} from './api';

/** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagListData} TagListData */
Expand Down Expand Up @@ -105,6 +106,17 @@ export const useContentTaxonomyTagsData = (contentId) => (
})
);

/**
* Build the query to get the count og taxonomy tags applied to the content object
* @param {string} contentId The ID of the content object to fetch the count of the applied tags for
*/
export const useContentTaxonomyTagsCount = (contentId) => (
useQuery({
queryKey: ['contentTaxonomyTagsCount', contentId],
queryFn: () => getContentTaxonomyTagsCount(contentId),
})
);

/**
* Builds the query to get meta data about the content object
* @param {string} contentId The id of the content object (unit/component)
Expand Down Expand Up @@ -139,6 +151,7 @@ export const useContentTaxonomyTagsUpdater = (contentId, taxonomyId) => {
queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTags', contentId] });
/// Invalidate query with pattern on course outline
queryClient.invalidateQueries({ queryKey: ['unitTagsCount'] });
queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTagsCount', contentId] });
},
});
};
19 changes: 19 additions & 0 deletions src/content-tags-drawer/data/apiHooks.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
useContentTaxonomyTagsData,
useContentData,
useContentTaxonomyTagsUpdater,
useContentTaxonomyTagsCount,
} from './apiHooks';

import { updateContentTaxonomyTags } from './api';
Expand Down Expand Up @@ -134,6 +135,24 @@ describe('useContentTaxonomyTagsData', () => {
});
});

describe('useContentTaxonomyTagsCount', () => {
it('should return success response', () => {
useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
const contentId = '123';
const result = useContentTaxonomyTagsCount(contentId);

expect(result).toEqual({ isSuccess: true, data: 'data' });
});

it('should return failure response', () => {
useQuery.mockReturnValueOnce({ isSuccess: false });
const contentId = '123';
const result = useContentTaxonomyTagsCount(contentId);

expect(result).toEqual({ isSuccess: false });
});
});

describe('useContentData', () => {
it('should return success response', () => {
useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
Expand Down
2 changes: 2 additions & 0 deletions src/content-tags-drawer/index.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
@import "content-tags-drawer/TagBubble";
@import "content-tags-drawer/tags-sidebar-controls/TagsSidebarControls";
10 changes: 10 additions & 0 deletions src/content-tags-drawer/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ const messages = defineMessages({
id: 'course-authoring.content-tags-drawer.content-tags-collapsible.selectable-box.selection.aria.label',
defaultMessage: 'taxonomy tags selection',
},
manageTagsButton: {
id: 'course-authoring.content-tags-drawer.button.manage',
defaultMessage: 'Manage Tags',
description: 'Label in the button that opens the drawer to edit content tags',
},
tagsSidebarTitle: {
id: 'course-authoring.course-unit.sidebar.tags.title',
defaultMessage: 'Unit Tags',
description: 'Title of the tags sidebar',
},
collapsibleAddTagsPlaceholderText: {
id: 'course-authoring.content-tags-drawer.content-tags-collapsible.custom-menu.placeholder-text',
defaultMessage: 'Add a tag',
Expand Down
112 changes: 112 additions & 0 deletions src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// @ts-check
import React, { useState, useMemo } from 'react';
import {
Card, Stack, Button, Sheet, Collapsible, Icon,
} from '@openedx/paragon';
import { ArrowDropDown, ArrowDropUp } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useParams } from 'react-router-dom';
import { ContentTagsDrawer } from '..';

import messages from '../messages';
import { useContentTaxonomyTagsData } from '../data/apiHooks';
import { LoadingSpinner } from '../../generic/Loading';
import TagsTree from './TagsTree';

const TagsSidebarBody = () => {
const intl = useIntl();
const [showManageTags, setShowManageTags] = useState(false);
const contentId = useParams().blockId;
const onClose = () => setShowManageTags(false);

const {
data: contentTaxonomyTagsData,
isSuccess: isContentTaxonomyTagsLoaded,
} = useContentTaxonomyTagsData(contentId || '');

const buildTagsTree = (contentTags) => {
const resultTree = {};
contentTags.forEach(item => {
let currentLevel = resultTree;

item.lineage.forEach((key) => {
if (!currentLevel[key]) {
currentLevel[key] = {
children: {},
canChangeObjecttag: item.canChangeObjecttag,
canDeleteObjecttag: item.canDeleteObjecttag,
};
}

currentLevel = currentLevel[key].children;
});
});

return resultTree;
};

const tree = useMemo(() => {
const result = [];
if (isContentTaxonomyTagsLoaded && contentTaxonomyTagsData) {
contentTaxonomyTagsData.taxonomies.forEach((taxonomy) => {
result.push({
...taxonomy,
tags: buildTagsTree(taxonomy.tags),
});
});
}
return result;
}, [isContentTaxonomyTagsLoaded, contentTaxonomyTagsData]);

return (
<>
<Card.Body
className="course-unit-sidebar-date tags-sidebar-body pl-2.5"
>
<Stack>
{ isContentTaxonomyTagsLoaded
? (
<Stack>
{tree.map((taxonomy) => (
<div key={taxonomy.name}>
<Collapsible
className="tags-sidebar-taxonomy border-0 .font-weight-bold"
styling="card"
title={taxonomy.name}
iconWhenClosed={<Icon src={ArrowDropDown} />}
iconWhenOpen={<Icon src={ArrowDropUp} />}
>
<TagsTree tags={taxonomy.tags} parentKey={taxonomy.name} />
</Collapsible>
</div>
))}
</Stack>
)
: (
<div className="d-flex justify-content-center">
<LoadingSpinner />
</div>
)}

<Button className="mt-3 ml-2" variant="outline-primary" size="sm" onClick={() => setShowManageTags(true)}>
{intl.formatMessage(messages.manageTagsButton)}
</Button>
</Stack>
</Card.Body>
<Sheet
position="right"
show={showManageTags}
onClose={onClose}
>
<ContentTagsDrawer
id={contentId}
onClose={onClose}
/>
</Sheet>
</>
);
};

TagsSidebarBody.propTypes = {};

export default TagsSidebarBody;
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import TagsSidebarBody from './TagsSidebarBody';
import { useContentTaxonomyTagsData } from '../data/apiHooks';
import { contentTaxonomyTagsMock } from '../__mocks__';

const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';

jest.mock('../data/apiHooks', () => ({
useContentTaxonomyTagsData: jest.fn(() => ({
isSuccess: false,
data: {},
})),
}));
jest.mock('../ContentTagsDrawer', () => jest.fn(() => <div>Mocked ContentTagsDrawer</div>));

const RootWrapper = () => (
<IntlProvider locale="en" messages={{}}>
<TagsSidebarBody />
</IntlProvider>
);

describe('<TagSidebarBody>', () => {
it('shows spinner before the content data query is complete', () => {
render(<RootWrapper />);
expect(screen.getByRole('status')).toBeInTheDocument();
});

it('should render data after wuery is complete', () => {
useContentTaxonomyTagsData.mockReturnValue({
isSuccess: true,
data: contentTaxonomyTagsMock[contentId],
});
render(<RootWrapper />);
const taxonomyButton = screen.getByRole('button', { name: /hierarchicaltaxonomy/i });
expect(taxonomyButton).toBeInTheDocument();

/// ContentTagsDrawer must be closed
expect(screen.queryByText('Mocked ContentTagsDrawer')).not.toBeInTheDocument();
});

it('should open ContentTagsDrawer', () => {
useContentTaxonomyTagsData.mockReturnValue({
isSuccess: true,
data: contentTaxonomyTagsMock[contentId],
});
render(<RootWrapper />);

const manageButton = screen.getByRole('button', { name: /manage tags/i });
fireEvent.click(manageButton);

expect(screen.getByText('Mocked ContentTagsDrawer')).toBeInTheDocument();
});
});
Loading

0 comments on commit d57ecc6

Please sign in to comment.