Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FC-0036] Tags Sidebar #852

Merged
merged 18 commits into from
Mar 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
};
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 @@
getContentTaxonomyTagsData,
getContentData,
updateContentTaxonomyTags,
getContentTaxonomyTagsCount,
} from './api';

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

/**
* 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),

Check warning on line 116 in src/content-tags-drawer/data/apiHooks.jsx

View check run for this annotation

Codecov / codecov/patch

src/content-tags-drawer/data/apiHooks.jsx#L116

Added line #L116 was not covered by tests
})
);

/**
* 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 @@
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',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be awesome if you could add some descriptions to these new messages. We'll be making them a requirement pretty soon. See openedx/frontend-build#517.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done c9261e4

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
Loading