[FC-0036] Tags Sidebar (#852)
* 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 <romulo@opencraft.com>
This commit is contained in:
2
package-lock.json
generated
2
package-lock.json
generated
@@ -54,7 +54,7 @@
|
||||
"react-responsive": "9.0.2",
|
||||
"react-router": "6.16.0",
|
||||
"react-router-dom": "6.16.0",
|
||||
"react-select": "^5.8.0",
|
||||
"react-select": "5.8.0",
|
||||
"react-textarea-autosize": "^8.4.1",
|
||||
"react-transition-group": "4.4.5",
|
||||
"redux": "4.0.5",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b': 20,
|
||||
};
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import {
|
||||
taxonomyTagsMock,
|
||||
contentTaxonomyTagsMock,
|
||||
contentTaxonomyTagsCountMock,
|
||||
contentDataMock,
|
||||
updateContentTaxonomyTagsMock,
|
||||
} from '../__mocks__';
|
||||
@@ -19,6 +20,8 @@ import {
|
||||
getContentTaxonomyTagsData,
|
||||
getContentData,
|
||||
updateContentTaxonomyTags,
|
||||
getContentTaxonomyTagsCountApiUrl,
|
||||
getContentTaxonomyTagsCount,
|
||||
} from './api';
|
||||
|
||||
let axiosMock;
|
||||
@@ -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);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getContentTaxonomyTagsData,
|
||||
getContentData,
|
||||
updateContentTaxonomyTags,
|
||||
getContentTaxonomyTagsCount,
|
||||
} from './api';
|
||||
|
||||
/** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagListData} TagListData */
|
||||
@@ -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)
|
||||
@@ -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] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useContentTaxonomyTagsData,
|
||||
useContentData,
|
||||
useContentTaxonomyTagsUpdater,
|
||||
useContentTaxonomyTagsCount,
|
||||
} from './apiHooks';
|
||||
|
||||
import { updateContentTaxonomyTags } from './api';
|
||||
@@ -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' });
|
||||
|
||||
2
src/content-tags-drawer/index.scss
Normal file
2
src/content-tags-drawer/index.scss
Normal file
@@ -0,0 +1,2 @@
|
||||
@import "content-tags-drawer/TagBubble";
|
||||
@import "content-tags-drawer/tags-sidebar-controls/TagsSidebarControls";
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
.tags-sidebar {
|
||||
.tags-sidebar-body {
|
||||
.tags-sidebar-taxonomy {
|
||||
.collapsible-trigger {
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
justify-content: start;
|
||||
padding-left: 0;
|
||||
padding-bottom: 0;
|
||||
|
||||
.collapsible-icon {
|
||||
order: -1;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.collapsible-body {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// @ts-check
|
||||
import React from 'react';
|
||||
import { Stack } from '@openedx/paragon';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from '../messages';
|
||||
import { useContentTaxonomyTagsCount } from '../data/apiHooks';
|
||||
import TagCount from '../../generic/tag-count';
|
||||
|
||||
const TagsSidebarHeader = () => {
|
||||
const intl = useIntl();
|
||||
const contentId = useParams().blockId;
|
||||
|
||||
const {
|
||||
data: contentTaxonomyTagsCount,
|
||||
isSuccess: isContentTaxonomyTagsCountLoaded,
|
||||
} = useContentTaxonomyTagsCount(contentId || '');
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className="course-unit-sidebar-header justify-content-between pb-1"
|
||||
direction="horizontal"
|
||||
>
|
||||
<h3 className="course-unit-sidebar-header-title m-0">
|
||||
{intl.formatMessage(messages.tagsSidebarTitle)}
|
||||
</h3>
|
||||
{ isContentTaxonomyTagsCountLoaded
|
||||
&& <TagCount count={contentTaxonomyTagsCount} />}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
TagsSidebarHeader.propTypes = {};
|
||||
|
||||
export default TagsSidebarHeader;
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import TagsSidebarHeader from './TagsSidebarHeader';
|
||||
import { useContentTaxonomyTagsCount } from '../data/apiHooks';
|
||||
|
||||
jest.mock('../data/apiHooks', () => ({
|
||||
useContentTaxonomyTagsCount: jest.fn(() => ({
|
||||
isSuccess: false,
|
||||
data: 17,
|
||||
})),
|
||||
}));
|
||||
|
||||
const RootWrapper = () => (
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<TagsSidebarHeader />
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
describe('<TagsSidebarHeader>', () => {
|
||||
it('should not render count on loading', () => {
|
||||
render(<RootWrapper />);
|
||||
expect(screen.getByRole('heading', { name: /unit tags/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText('17')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render count after query is complete', () => {
|
||||
useContentTaxonomyTagsCount.mockReturnValue({
|
||||
isSuccess: true,
|
||||
data: 17,
|
||||
});
|
||||
render(<RootWrapper />);
|
||||
expect(screen.getByRole('heading', { name: /unit tags/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('17')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
50
src/content-tags-drawer/tags-sidebar-controls/TagsTree.jsx
Normal file
50
src/content-tags-drawer/tags-sidebar-controls/TagsTree.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
// @ts-check
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Icon } from '@openedx/paragon';
|
||||
import { Tag } from '@openedx/paragon/icons';
|
||||
|
||||
const TagsTree = ({ tags, rootDepth, parentKey }) => {
|
||||
if (Object.keys(tags).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Used to Generate tabs for the parents of this tree
|
||||
const tabsNumberArray = Array.from({ length: rootDepth }, (_, index) => index + 1);
|
||||
|
||||
return (
|
||||
<div className="tags-tree" key={parentKey}>
|
||||
{Object.keys(tags).map((key) => (
|
||||
<div className="mt-1.5 mb-1.5" key={key}>
|
||||
<div className="d-flex pl-2.5" key={key}>
|
||||
{
|
||||
tabsNumberArray.map((index) => <span className="d-inline-block ml-4" key={`${key}-${index}`} />)
|
||||
}
|
||||
<Icon src={Tag} className="mr-1 pb-1.5 text-info-500" />{key}
|
||||
</div>
|
||||
{ tags[key].children
|
||||
&& (
|
||||
<TagsTree
|
||||
tags={tags[key].children}
|
||||
rootDepth={rootDepth + 1}
|
||||
parentKey={key}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
TagsTree.propTypes = {
|
||||
tags: PropTypes.shape({}).isRequired,
|
||||
parentKey: PropTypes.string,
|
||||
rootDepth: PropTypes.number,
|
||||
};
|
||||
|
||||
TagsTree.defaultProps = {
|
||||
rootDepth: 0,
|
||||
parentKey: undefined,
|
||||
};
|
||||
|
||||
export default TagsTree;
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import TagsTree from './TagsTree';
|
||||
import { contentTaxonomyTagsTreeMock } from '../__mocks__';
|
||||
|
||||
describe('<TagsTree>', () => {
|
||||
it('should render component and tags correctly', () => {
|
||||
render(<TagsTree tags={contentTaxonomyTagsTreeMock} />);
|
||||
expect(screen.getByText('hierarchical taxonomy tag 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('hierarchical taxonomy tag 2.13')).toBeInTheDocument();
|
||||
expect(screen.getByText('hierarchical taxonomy tag 3.4.50')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
13
src/content-tags-drawer/tags-sidebar-controls/index.jsx
Normal file
13
src/content-tags-drawer/tags-sidebar-controls/index.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import TagsSidebarHeader from './TagsSidebarHeader';
|
||||
import TagsSidebarBody from './TagsSidebarBody';
|
||||
|
||||
const TagsSidebarControls = () => (
|
||||
<>
|
||||
<TagsSidebarHeader />
|
||||
<TagsSidebarBody />
|
||||
</>
|
||||
);
|
||||
|
||||
TagsSidebarControls.propTypes = {};
|
||||
|
||||
export default TagsSidebarControls;
|
||||
@@ -24,6 +24,9 @@ import Sequence from './course-sequence';
|
||||
import Sidebar from './sidebar';
|
||||
import { useCourseUnit } from './hooks';
|
||||
import messages from './messages';
|
||||
import PublishControls from './sidebar/PublishControls';
|
||||
import LocationInfo from './sidebar/LocationInfo';
|
||||
import TagsSidebarControls from '../content-tags-drawer/tags-sidebar-controls';
|
||||
|
||||
const CourseUnit = ({ courseId }) => {
|
||||
const { blockId } = useParams();
|
||||
@@ -133,8 +136,15 @@ const CourseUnit = ({ courseId }) => {
|
||||
</Layout.Element>
|
||||
<Layout.Element>
|
||||
<Stack gap={3}>
|
||||
<Sidebar blockId={blockId} data-testid="course-unit-sidebar" />
|
||||
<Sidebar displayUnitLocation data-testid="course-unit-location-sidebar" />
|
||||
<Sidebar data-testid="course-unit-sidebar">
|
||||
<PublishControls blockId={blockId} />
|
||||
</Sidebar>
|
||||
<Sidebar className="tags-sidebar">
|
||||
<TagsSidebarControls />
|
||||
</Sidebar>
|
||||
<Sidebar data-testid="course-unit-location-sidebar">
|
||||
<LocationInfo />
|
||||
</Sidebar>
|
||||
</Stack>
|
||||
</Layout.Element>
|
||||
</Layout>
|
||||
|
||||
@@ -44,6 +44,7 @@ import deleteModalMessages from '../generic/delete-modal/messages';
|
||||
import courseXBlockMessages from './course-xblock/messages';
|
||||
import addComponentMessages from './add-component/messages';
|
||||
import { PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants';
|
||||
import { getContentTaxonomyTagsApiUrl, getContentTaxonomyTagsCountApiUrl } from '../content-tags-drawer/data/api';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
@@ -59,6 +60,31 @@ jest.mock('react-router-dom', () => ({
|
||||
useNavigate: () => mockedUsedNavigate,
|
||||
}));
|
||||
|
||||
jest.mock('@tanstack/react-query', () => ({
|
||||
useQuery: jest.fn(({ queryKey }) => {
|
||||
if (queryKey[0] === 'contentTaxonomyTags') {
|
||||
return {
|
||||
data: {
|
||||
taxonomies: [],
|
||||
},
|
||||
isSuccess: true,
|
||||
};
|
||||
} if (queryKey[0] === 'contentTaxonomyTagsCount') {
|
||||
return {
|
||||
data: 17,
|
||||
isSuccess: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: {},
|
||||
isSuccess: true,
|
||||
};
|
||||
}),
|
||||
useQueryClient: jest.fn(() => ({
|
||||
setQueryData: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
@@ -92,6 +118,12 @@ describe('<CourseUnit />', () => {
|
||||
.onGet(getCourseVerticalChildrenApiUrl(blockId))
|
||||
.reply(200, courseVerticalChildrenMock);
|
||||
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
|
||||
axiosMock
|
||||
.onGet(getContentTaxonomyTagsApiUrl(blockId))
|
||||
.reply(200, {});
|
||||
axiosMock
|
||||
.onGet(getContentTaxonomyTagsCountApiUrl(blockId))
|
||||
.reply(200, 17);
|
||||
});
|
||||
|
||||
it('render CourseUnit component correctly', async () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@openedx/paragon';
|
||||
import { Plus as PlusIcon } from '@openedx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { changeEditTitleFormOpen, updateQueryPendingStatus } from '../../data/slice';
|
||||
import { getCourseId, getSequenceId } from '../../data/selectors';
|
||||
|
||||
38
src/course-unit/sidebar/LocationInfo.jsx
Normal file
38
src/course-unit/sidebar/LocationInfo.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import useCourseUnitData from './hooks';
|
||||
import { getCourseUnitData } from '../data/selectors';
|
||||
import { SidebarBody, SidebarFooter, SidebarHeader } from './components';
|
||||
|
||||
const LocationInfo = () => {
|
||||
const {
|
||||
title,
|
||||
locationId,
|
||||
releaseLabel,
|
||||
visibilityState,
|
||||
visibleToStaffOnly,
|
||||
} = useCourseUnitData(useSelector(getCourseUnitData));
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarHeader
|
||||
title={title}
|
||||
visibilityState={visibilityState}
|
||||
displayUnitLocation
|
||||
/>
|
||||
<SidebarBody
|
||||
locationId={locationId}
|
||||
releaseLabel={releaseLabel}
|
||||
displayUnitLocation
|
||||
/>
|
||||
<SidebarFooter
|
||||
locationId={locationId}
|
||||
visibleToStaffOnly={visibleToStaffOnly}
|
||||
displayUnitLocation
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
LocationInfo.propTypes = {};
|
||||
|
||||
export default LocationInfo;
|
||||
92
src/course-unit/sidebar/PublishControls.jsx
Normal file
92
src/course-unit/sidebar/PublishControls.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
import { InfoOutline as InfoOutlineIcon } from '@openedx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import useCourseUnitData from './hooks';
|
||||
import { editCourseUnitVisibilityAndData } from '../data/thunk';
|
||||
import { SidebarBody, SidebarFooter, SidebarHeader } from './components';
|
||||
import { PUBLISH_TYPES } from '../constants';
|
||||
import { getCourseUnitData } from '../data/selectors';
|
||||
import messages from './messages';
|
||||
import ModalNotification from '../../generic/modal-notification';
|
||||
|
||||
const PublishControls = ({ blockId }) => {
|
||||
const {
|
||||
title,
|
||||
locationId,
|
||||
releaseLabel,
|
||||
visibilityState,
|
||||
visibleToStaffOnly,
|
||||
} = useCourseUnitData(useSelector(getCourseUnitData));
|
||||
const intl = useIntl();
|
||||
|
||||
const [isDiscardModalOpen, openDiscardModal, closeDiscardModal] = useToggle(false);
|
||||
const [isVisibleModalOpen, openVisibleModal, closeVisibleModal] = useToggle(false);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleCourseUnitVisibility = () => {
|
||||
closeVisibleModal();
|
||||
dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.republish, null));
|
||||
};
|
||||
|
||||
const handleCourseUnitDiscardChanges = () => {
|
||||
closeDiscardModal();
|
||||
dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.discardChanges));
|
||||
};
|
||||
|
||||
const handleCourseUnitPublish = () => {
|
||||
dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarHeader
|
||||
title={title}
|
||||
visibilityState={visibilityState}
|
||||
/>
|
||||
<SidebarBody
|
||||
releaseLabel={releaseLabel}
|
||||
visibleToStaffOnly={visibleToStaffOnly}
|
||||
/>
|
||||
<SidebarFooter
|
||||
locationId={locationId}
|
||||
openDiscardModal={openDiscardModal}
|
||||
openVisibleModal={openVisibleModal}
|
||||
handlePublishing={handleCourseUnitPublish}
|
||||
visibleToStaffOnly={visibleToStaffOnly}
|
||||
/>
|
||||
<ModalNotification
|
||||
title={intl.formatMessage(messages.modalDiscardUnitChangesTitle)}
|
||||
isOpen={isDiscardModalOpen}
|
||||
actionButtonText={intl.formatMessage(messages.modalDiscardUnitChangesActionButtonText)}
|
||||
cancelButtonText={intl.formatMessage(messages.modalDiscardUnitChangesCancelButtonText)}
|
||||
handleAction={handleCourseUnitDiscardChanges}
|
||||
handleCancel={closeDiscardModal}
|
||||
message={intl.formatMessage(messages.modalDiscardUnitChangesDescription)}
|
||||
icon={InfoOutlineIcon}
|
||||
/>
|
||||
<ModalNotification
|
||||
title={intl.formatMessage(messages.modalMakeVisibilityTitle)}
|
||||
isOpen={isVisibleModalOpen}
|
||||
actionButtonText={intl.formatMessage(messages.modalMakeVisibilityActionButtonText)}
|
||||
cancelButtonText={intl.formatMessage(messages.modalMakeVisibilityCancelButtonText)}
|
||||
handleAction={handleCourseUnitVisibility}
|
||||
handleCancel={closeVisibleModal}
|
||||
message={intl.formatMessage(messages.modalMakeVisibilityDescription)}
|
||||
icon={InfoOutlineIcon}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
PublishControls.propTypes = {
|
||||
blockId: PropTypes.string,
|
||||
};
|
||||
|
||||
PublishControls.defaultProps = {
|
||||
blockId: null,
|
||||
};
|
||||
|
||||
export default PublishControls;
|
||||
@@ -68,9 +68,9 @@
|
||||
|
||||
@extend %base-font-params;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-stuff-only .course-unit-sidebar-date-and-with {
|
||||
text-decoration: line-through;
|
||||
&.is-stuff-only .course-unit-sidebar-date-and-with {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,18 @@ import { useSelector } from 'react-redux';
|
||||
import { Card, Stack } from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { getCourseUnitData } from '../../data/selectors';
|
||||
import { getPublishInfo } from '../utils';
|
||||
import messages from '../messages';
|
||||
import ReleaseInfoComponent from './ReleaseInfoComponent';
|
||||
|
||||
const SidebarBody = ({ releaseLabel, displayUnitLocation, locationId }) => {
|
||||
const SidebarBody = ({
|
||||
releaseLabel,
|
||||
displayUnitLocation,
|
||||
locationId,
|
||||
visibleToStaffOnly,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
editedOn,
|
||||
@@ -19,7 +25,10 @@ const SidebarBody = ({ releaseLabel, displayUnitLocation, locationId }) => {
|
||||
} = useSelector(getCourseUnitData);
|
||||
|
||||
return (
|
||||
<Card.Body className="course-unit-sidebar-date">
|
||||
<Card.Body className={classNames('course-unit-sidebar-date', {
|
||||
'is-stuff-only': visibleToStaffOnly,
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
{displayUnitLocation ? (
|
||||
<span>
|
||||
@@ -55,11 +64,13 @@ SidebarBody.propTypes = {
|
||||
releaseLabel: PropTypes.string.isRequired,
|
||||
displayUnitLocation: PropTypes.bool,
|
||||
locationId: PropTypes.string,
|
||||
visibleToStaffOnly: PropTypes.bool,
|
||||
};
|
||||
|
||||
SidebarBody.defaultProps = {
|
||||
displayUnitLocation: false,
|
||||
locationId: null,
|
||||
visibleToStaffOnly: false,
|
||||
};
|
||||
|
||||
export default SidebarBody;
|
||||
|
||||
@@ -1,102 +1,24 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import classNames from 'classnames';
|
||||
import { Card, useToggle } from '@openedx/paragon';
|
||||
import { InfoOutline as InfoOutlineIcon } from '@openedx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Card } from '@openedx/paragon';
|
||||
|
||||
import ModalNotification from '../../generic/modal-notification';
|
||||
import { editCourseUnitVisibilityAndData } from '../data/thunk';
|
||||
import { getCourseUnitData } from '../data/selectors';
|
||||
import { PUBLISH_TYPES } from '../constants';
|
||||
import { SidebarBody, SidebarFooter, SidebarHeader } from './components';
|
||||
import useCourseUnitData from './hooks';
|
||||
import messages from './messages';
|
||||
|
||||
const Sidebar = ({ blockId, displayUnitLocation, ...props }) => {
|
||||
const {
|
||||
title,
|
||||
locationId,
|
||||
releaseLabel,
|
||||
visibilityState,
|
||||
visibleToStaffOnly,
|
||||
} = useCourseUnitData(useSelector(getCourseUnitData));
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const [isDiscardModalOpen, openDiscardModal, closeDiscardModal] = useToggle(false);
|
||||
const [isVisibleModalOpen, openVisibleModal, closeVisibleModal] = useToggle(false);
|
||||
|
||||
const handleCourseUnitVisibility = () => {
|
||||
closeVisibleModal();
|
||||
dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.republish, null));
|
||||
};
|
||||
|
||||
const handleCourseUnitDiscardChanges = () => {
|
||||
closeDiscardModal();
|
||||
dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.discardChanges));
|
||||
};
|
||||
|
||||
const handleCourseUnitPublish = () => {
|
||||
dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={classNames('course-unit-sidebar', {
|
||||
'is-stuff-only': visibleToStaffOnly,
|
||||
})}
|
||||
{...props}
|
||||
>
|
||||
<SidebarHeader
|
||||
title={title}
|
||||
visibilityState={visibilityState}
|
||||
displayUnitLocation={displayUnitLocation}
|
||||
/>
|
||||
<SidebarBody
|
||||
locationId={locationId}
|
||||
releaseLabel={releaseLabel}
|
||||
displayUnitLocation={displayUnitLocation}
|
||||
/>
|
||||
<SidebarFooter
|
||||
locationId={locationId}
|
||||
openDiscardModal={openDiscardModal}
|
||||
openVisibleModal={openVisibleModal}
|
||||
displayUnitLocation={displayUnitLocation}
|
||||
handlePublishing={handleCourseUnitPublish}
|
||||
visibleToStaffOnly={visibleToStaffOnly}
|
||||
/>
|
||||
<ModalNotification
|
||||
title={intl.formatMessage(messages.modalDiscardUnitChangesTitle)}
|
||||
isOpen={isDiscardModalOpen}
|
||||
actionButtonText={intl.formatMessage(messages.modalDiscardUnitChangesActionButtonText)}
|
||||
cancelButtonText={intl.formatMessage(messages.modalDiscardUnitChangesCancelButtonText)}
|
||||
handleAction={handleCourseUnitDiscardChanges}
|
||||
handleCancel={closeDiscardModal}
|
||||
message={intl.formatMessage(messages.modalDiscardUnitChangesDescription)}
|
||||
icon={InfoOutlineIcon}
|
||||
/>
|
||||
<ModalNotification
|
||||
title={intl.formatMessage(messages.modalMakeVisibilityTitle)}
|
||||
isOpen={isVisibleModalOpen}
|
||||
actionButtonText={intl.formatMessage(messages.modalMakeVisibilityActionButtonText)}
|
||||
cancelButtonText={intl.formatMessage(messages.modalMakeVisibilityCancelButtonText)}
|
||||
handleAction={handleCourseUnitVisibility}
|
||||
handleCancel={closeVisibleModal}
|
||||
message={intl.formatMessage(messages.modalMakeVisibilityDescription)}
|
||||
icon={InfoOutlineIcon}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
const Sidebar = ({ className, children, ...props }) => (
|
||||
<Card
|
||||
className={classNames('course-unit-sidebar', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
|
||||
Sidebar.propTypes = {
|
||||
blockId: PropTypes.string,
|
||||
displayUnitLocation: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
Sidebar.defaultProps = {
|
||||
blockId: null,
|
||||
displayUnitLocation: false,
|
||||
className: null,
|
||||
children: null,
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
|
||||
@@ -6,3 +6,4 @@
|
||||
@import "./create-or-rerun-course/CreateOrRerunCourseForm";
|
||||
@import "./WysiwygEditor";
|
||||
@import "./course-stepper/CouseStepper";
|
||||
@import "./tag-count/TagCount";
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
@import "import-page/CourseImportPage";
|
||||
@import "taxonomy";
|
||||
@import "files-and-videos";
|
||||
@import "content-tags-drawer/TagBubble";
|
||||
@import "content-tags-drawer";
|
||||
@import "course-outline/CourseOutline";
|
||||
@import "course-unit/CourseUnit";
|
||||
@import "course-checklist/CourseChecklist";
|
||||
|
||||
Reference in New Issue
Block a user