From d57ecc6779a067778f60e9acdd7f426445564d3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Ch=C3=A1vez?= Date: Fri, 15 Mar 2024 11:29:28 -0500 Subject: [PATCH] [FC-0036] Tags Sidebar (#852) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- package-lock.json | 2 +- src/content-tags-drawer/ContentTagsDrawer.jsx | 3 + .../__mocks__/contentTaxonomyTagsCountMock.js | 3 + .../__mocks__/contentTaxonomyTagsTreeMock.js | 35 ++++++ src/content-tags-drawer/__mocks__/index.js | 2 + src/content-tags-drawer/data/api.js | 14 +++ src/content-tags-drawer/data/api.test.js | 21 ++++ src/content-tags-drawer/data/apiHooks.jsx | 13 ++ .../data/apiHooks.test.jsx | 19 +++ src/content-tags-drawer/index.scss | 2 + src/content-tags-drawer/messages.js | 10 ++ .../tags-sidebar-controls/TagsSidebarBody.jsx | 112 ++++++++++++++++++ .../TagsSidebarBody.test.jsx | 55 +++++++++ .../TagsSidebarControls.scss | 23 ++++ .../TagsSidebarHeader.jsx | 36 ++++++ .../TagsSidebarHeader.test.jsx | 36 ++++++ .../tags-sidebar-controls/TagsTree.jsx | 50 ++++++++ .../tags-sidebar-controls/TagsTree.test.jsx | 13 ++ .../tags-sidebar-controls/index.jsx | 13 ++ src/course-unit/CourseUnit.jsx | 14 ++- src/course-unit/CourseUnit.test.jsx | 32 +++++ .../SequenceNavigationTabs.jsx | 2 +- src/course-unit/sidebar/LocationInfo.jsx | 38 ++++++ src/course-unit/sidebar/PublishControls.jsx | 92 ++++++++++++++ src/course-unit/sidebar/Sidebar.scss | 6 +- .../sidebar/components/SidebarBody.jsx | 15 ++- src/course-unit/sidebar/index.jsx | 104 ++-------------- src/generic/styles.scss | 1 + src/index.scss | 2 +- 29 files changed, 667 insertions(+), 101 deletions(-) create mode 100644 src/content-tags-drawer/__mocks__/contentTaxonomyTagsCountMock.js create mode 100644 src/content-tags-drawer/__mocks__/contentTaxonomyTagsTreeMock.js create mode 100644 src/content-tags-drawer/index.scss create mode 100644 src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.jsx create mode 100644 src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.test.jsx create mode 100644 src/content-tags-drawer/tags-sidebar-controls/TagsSidebarControls.scss create mode 100644 src/content-tags-drawer/tags-sidebar-controls/TagsSidebarHeader.jsx create mode 100644 src/content-tags-drawer/tags-sidebar-controls/TagsSidebarHeader.test.jsx create mode 100644 src/content-tags-drawer/tags-sidebar-controls/TagsTree.jsx create mode 100644 src/content-tags-drawer/tags-sidebar-controls/TagsTree.test.jsx create mode 100644 src/content-tags-drawer/tags-sidebar-controls/index.jsx create mode 100644 src/course-unit/sidebar/LocationInfo.jsx create mode 100644 src/course-unit/sidebar/PublishControls.jsx diff --git a/package-lock.json b/package-lock.json index a4d615c222..5186ae7592 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/src/content-tags-drawer/ContentTagsDrawer.jsx b/src/content-tags-drawer/ContentTagsDrawer.jsx index 853930b686..d0f987b12f 100644 --- a/src/content-tags-drawer/ContentTagsDrawer.jsx +++ b/src/content-tags-drawer/ContentTagsDrawer.jsx @@ -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; } diff --git a/src/content-tags-drawer/__mocks__/contentTaxonomyTagsCountMock.js b/src/content-tags-drawer/__mocks__/contentTaxonomyTagsCountMock.js new file mode 100644 index 0000000000..3ce4d2050a --- /dev/null +++ b/src/content-tags-drawer/__mocks__/contentTaxonomyTagsCountMock.js @@ -0,0 +1,3 @@ +module.exports = { + 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b': 20, +}; diff --git a/src/content-tags-drawer/__mocks__/contentTaxonomyTagsTreeMock.js b/src/content-tags-drawer/__mocks__/contentTaxonomyTagsTreeMock.js new file mode 100644 index 0000000000..687e3d357b --- /dev/null +++ b/src/content-tags-drawer/__mocks__/contentTaxonomyTagsTreeMock.js @@ -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: {}, + }, + }, + }, + }, + }, +}; diff --git a/src/content-tags-drawer/__mocks__/index.js b/src/content-tags-drawer/__mocks__/index.js index 5ec3027386..8c4274d643 100644 --- a/src/content-tags-drawer/__mocks__/index.js +++ b/src/content-tags-drawer/__mocks__/index.js @@ -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'; diff --git a/src/content-tags-drawer/data/api.js b/src/content-tags-drawer/data/api.js index 28bd7a36c8..86ee7746d9 100644 --- a/src/content-tags-drawer/data/api.js +++ b/src/content-tags-drawer/data/api.js @@ -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} + */ +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) diff --git a/src/content-tags-drawer/data/api.test.js b/src/content-tags-drawer/data/api.test.js index 9fa88dcb79..7ccb353548 100644 --- a/src/content-tags-drawer/data/api.test.js +++ b/src/content-tags-drawer/data/api.test.js @@ -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); diff --git a/src/content-tags-drawer/data/apiHooks.jsx b/src/content-tags-drawer/data/apiHooks.jsx index 82e9a700ca..5c24c0aa6b 100644 --- a/src/content-tags-drawer/data/apiHooks.jsx +++ b/src/content-tags-drawer/data/apiHooks.jsx @@ -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] }); }, }); }; diff --git a/src/content-tags-drawer/data/apiHooks.test.jsx b/src/content-tags-drawer/data/apiHooks.test.jsx index 4e12ef5ea5..127d71cc5b 100644 --- a/src/content-tags-drawer/data/apiHooks.test.jsx +++ b/src/content-tags-drawer/data/apiHooks.test.jsx @@ -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' }); diff --git a/src/content-tags-drawer/index.scss b/src/content-tags-drawer/index.scss new file mode 100644 index 0000000000..d179bf86ab --- /dev/null +++ b/src/content-tags-drawer/index.scss @@ -0,0 +1,2 @@ +@import "content-tags-drawer/TagBubble"; +@import "content-tags-drawer/tags-sidebar-controls/TagsSidebarControls"; diff --git a/src/content-tags-drawer/messages.js b/src/content-tags-drawer/messages.js index 4d67ccc729..47a8c1bc86 100644 --- a/src/content-tags-drawer/messages.js +++ b/src/content-tags-drawer/messages.js @@ -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', diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.jsx b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.jsx new file mode 100644 index 0000000000..29b2e244d6 --- /dev/null +++ b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.jsx @@ -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 ( + <> + + + { isContentTaxonomyTagsLoaded + ? ( + + {tree.map((taxonomy) => ( +
+ } + iconWhenOpen={} + > + + +
+ ))} +
+ ) + : ( +
+ +
+ )} + + +
+
+ + + + + ); +}; + +TagsSidebarBody.propTypes = {}; + +export default TagsSidebarBody; diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.test.jsx b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.test.jsx new file mode 100644 index 0000000000..32be90bf44 --- /dev/null +++ b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.test.jsx @@ -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(() =>
Mocked ContentTagsDrawer
)); + +const RootWrapper = () => ( + + + +); + +describe('', () => { + it('shows spinner before the content data query is complete', () => { + render(); + expect(screen.getByRole('status')).toBeInTheDocument(); + }); + + it('should render data after wuery is complete', () => { + useContentTaxonomyTagsData.mockReturnValue({ + isSuccess: true, + data: contentTaxonomyTagsMock[contentId], + }); + render(); + 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(); + + const manageButton = screen.getByRole('button', { name: /manage tags/i }); + fireEvent.click(manageButton); + + expect(screen.getByText('Mocked ContentTagsDrawer')).toBeInTheDocument(); + }); +}); diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarControls.scss b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarControls.scss new file mode 100644 index 0000000000..a3c0978f8c --- /dev/null +++ b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarControls.scss @@ -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; + } + } + } +} diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarHeader.jsx b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarHeader.jsx new file mode 100644 index 0000000000..e3927deb89 --- /dev/null +++ b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarHeader.jsx @@ -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 ( + +

+ {intl.formatMessage(messages.tagsSidebarTitle)} +

+ { isContentTaxonomyTagsCountLoaded + && } +
+ ); +}; + +TagsSidebarHeader.propTypes = {}; + +export default TagsSidebarHeader; diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarHeader.test.jsx b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarHeader.test.jsx new file mode 100644 index 0000000000..ab0f9339e8 --- /dev/null +++ b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarHeader.test.jsx @@ -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 = () => ( + + + +); + +describe('', () => { + it('should not render count on loading', () => { + render(); + 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(); + expect(screen.getByRole('heading', { name: /unit tags/i })).toBeInTheDocument(); + expect(screen.getByText('17')).toBeInTheDocument(); + }); +}); diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsTree.jsx b/src/content-tags-drawer/tags-sidebar-controls/TagsTree.jsx new file mode 100644 index 0000000000..df9923271a --- /dev/null +++ b/src/content-tags-drawer/tags-sidebar-controls/TagsTree.jsx @@ -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 ( +
+ {Object.keys(tags).map((key) => ( +
+
+ { + tabsNumberArray.map((index) => ) + } + {key} +
+ { tags[key].children + && ( + + )} +
+ ))} +
+ ); +}; + +TagsTree.propTypes = { + tags: PropTypes.shape({}).isRequired, + parentKey: PropTypes.string, + rootDepth: PropTypes.number, +}; + +TagsTree.defaultProps = { + rootDepth: 0, + parentKey: undefined, +}; + +export default TagsTree; diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsTree.test.jsx b/src/content-tags-drawer/tags-sidebar-controls/TagsTree.test.jsx new file mode 100644 index 0000000000..0ca8c5333a --- /dev/null +++ b/src/content-tags-drawer/tags-sidebar-controls/TagsTree.test.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import TagsTree from './TagsTree'; +import { contentTaxonomyTagsTreeMock } from '../__mocks__'; + +describe('', () => { + it('should render component and tags correctly', () => { + render(); + 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(); + }); +}); diff --git a/src/content-tags-drawer/tags-sidebar-controls/index.jsx b/src/content-tags-drawer/tags-sidebar-controls/index.jsx new file mode 100644 index 0000000000..98ffc5e7c4 --- /dev/null +++ b/src/content-tags-drawer/tags-sidebar-controls/index.jsx @@ -0,0 +1,13 @@ +import TagsSidebarHeader from './TagsSidebarHeader'; +import TagsSidebarBody from './TagsSidebarBody'; + +const TagsSidebarControls = () => ( + <> + + + +); + +TagsSidebarControls.propTypes = {}; + +export default TagsSidebarControls; diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index b2cda53184..25691135c4 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -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 }) => { - - + + + + + + + + + diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index a4d4337bb5..5a9685aea3 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -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 = () => ( @@ -92,6 +118,12 @@ describe('', () => { .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 () => { diff --git a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx index 370488ce06..7565a8c0d1 100644 --- a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx +++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx @@ -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'; diff --git a/src/course-unit/sidebar/LocationInfo.jsx b/src/course-unit/sidebar/LocationInfo.jsx new file mode 100644 index 0000000000..1d63180883 --- /dev/null +++ b/src/course-unit/sidebar/LocationInfo.jsx @@ -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 ( + <> + + + + + ); +}; + +LocationInfo.propTypes = {}; + +export default LocationInfo; diff --git a/src/course-unit/sidebar/PublishControls.jsx b/src/course-unit/sidebar/PublishControls.jsx new file mode 100644 index 0000000000..424594f35b --- /dev/null +++ b/src/course-unit/sidebar/PublishControls.jsx @@ -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 ( + <> + + + + + + + ); +}; + +PublishControls.propTypes = { + blockId: PropTypes.string, +}; + +PublishControls.defaultProps = { + blockId: null, +}; + +export default PublishControls; diff --git a/src/course-unit/sidebar/Sidebar.scss b/src/course-unit/sidebar/Sidebar.scss index 954e20d4b2..0fbae7eb6a 100644 --- a/src/course-unit/sidebar/Sidebar.scss +++ b/src/course-unit/sidebar/Sidebar.scss @@ -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; + } } } diff --git a/src/course-unit/sidebar/components/SidebarBody.jsx b/src/course-unit/sidebar/components/SidebarBody.jsx index 679384377d..b7dce23a23 100644 --- a/src/course-unit/sidebar/components/SidebarBody.jsx +++ b/src/course-unit/sidebar/components/SidebarBody.jsx @@ -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 ( - + {displayUnitLocation ? ( @@ -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; diff --git a/src/course-unit/sidebar/index.jsx b/src/course-unit/sidebar/index.jsx index f2817639b2..a7697c8abd 100644 --- a/src/course-unit/sidebar/index.jsx +++ b/src/course-unit/sidebar/index.jsx @@ -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 ( - - - - - - - - ); -}; +const Sidebar = ({ className, children, ...props }) => ( + + {children} + +); 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; diff --git a/src/generic/styles.scss b/src/generic/styles.scss index becfb9a77a..0a8dde0e9a 100644 --- a/src/generic/styles.scss +++ b/src/generic/styles.scss @@ -6,3 +6,4 @@ @import "./create-or-rerun-course/CreateOrRerunCourseForm"; @import "./WysiwygEditor"; @import "./course-stepper/CouseStepper"; +@import "./tag-count/TagCount"; diff --git a/src/index.scss b/src/index.scss index 79f44950cc..fc03c9f773 100755 --- a/src/index.scss +++ b/src/index.scss @@ -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";