diff --git a/src/content-tags-drawer/ContentTagsCollapsible.jsx b/src/content-tags-drawer/ContentTagsCollapsible.jsx
new file mode 100644
index 0000000000..5e27f8291c
--- /dev/null
+++ b/src/content-tags-drawer/ContentTagsCollapsible.jsx
@@ -0,0 +1,117 @@
+import React from 'react';
+import {
+ Badge,
+ Collapsible,
+ SelectableBox,
+ Button,
+ ModalPopup,
+ useToggle,
+} from '@edx/paragon';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
+import messages from './messages';
+import './ContentTagsCollapsible.scss';
+
+import ContentTagsDropDownSelector from './ContentTagsDropDownSelector';
+
+import ContentTagsTree from './ContentTagsTree';
+
+/**
+ * Collapsible component that holds a Taxonomy along with Tags that belong to it.
+ * This includes both applied tags and tags that are available to select
+ * from a dropdown list.
+ * @param {Object} taxonomyAndTagsData - Object containing Taxonomy meta data along with applied tags
+ * @param {number} taxonomyAndTagsData.id - id of Taxonomy
+ * @param {string} taxonomyAndTagsData.name - name of Taxonomy
+ * @param {string} taxonomyAndTagsData.description - description of Taxonomy
+ * @param {boolean} taxonomyAndTagsData.enabled - Whether Taxonomy is enabled/disabled
+ * @param {boolean} taxonomyAndTagsData.allowMultiple - Whether Taxonomy allows multiple tags to be applied
+ * @param {boolean} taxonomyAndTagsData.allowFreeText - Whether Taxonomy allows free text tags
+ * @param {boolean} taxonomyAndTagsData.systemDefined - Whether Taxonomy is system defined or authored by user
+ * @param {boolean} taxonomyAndTagsData.visibleToAuthors - Whether Taxonomy should be visible to object authors
+ * @param {string[]} taxonomyAndTagsData.orgs - Array of orgs this Taxonomy belongs to
+ * @param {boolean} taxonomyAndTagsData.allOrgs - Whether Taxonomy belongs to all orgs
+ * @param {Object[]} taxonomyAndTagsData.contentTags - Array of taxonomy tags that are applied to the content
+ * @param {string} taxonomyAndTagsData.contentTags.value - Value of applied Tag
+ * @param {string} taxonomyAndTagsData.contentTags.lineage - Array of Tag's ancestors sorted (ancestor -> tag)
+ */
+const ContentTagsCollapsible = ({ taxonomyAndTagsData }) => {
+ const intl = useIntl();
+ const {
+ id, name, contentTags,
+ } = taxonomyAndTagsData;
+
+ const [isOpen, open, close] = useToggle(false);
+ const [target, setTarget] = React.useState(null);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {contentTags.length}
+
+
+
+ );
+};
+
+ContentTagsCollapsible.propTypes = {
+ taxonomyAndTagsData: PropTypes.shape({
+ id: PropTypes.number,
+ name: PropTypes.string,
+ contentTags: PropTypes.arrayOf(PropTypes.shape({
+ value: PropTypes.string,
+ lineage: PropTypes.arrayOf(PropTypes.string),
+ })),
+ }).isRequired,
+};
+
+export default ContentTagsCollapsible;
diff --git a/src/content-tags-drawer/ContentTagsCollapsible.scss b/src/content-tags-drawer/ContentTagsCollapsible.scss
new file mode 100644
index 0000000000..a207fd9474
--- /dev/null
+++ b/src/content-tags-drawer/ContentTagsCollapsible.scss
@@ -0,0 +1,24 @@
+.taxonomy-tags-collapsible {
+ flex: 1;
+ border: none !important;
+
+ .collapsible-trigger {
+ border: none !important;
+ }
+}
+
+.taxonomy-tags-selector-menu {
+ button {
+ flex: 1;
+ }
+}
+
+.taxonomy-tags-selector-menu + div {
+ width: 100%;
+}
+
+.taxonomy-tags-selectable-box-set {
+ grid-auto-rows: unset !important;
+ overflow-y: scroll;
+ max-height: 20rem;
+}
diff --git a/src/content-tags-drawer/ContentTagsCollapsible.test.jsx b/src/content-tags-drawer/ContentTagsCollapsible.test.jsx
new file mode 100644
index 0000000000..eca8c94bfc
--- /dev/null
+++ b/src/content-tags-drawer/ContentTagsCollapsible.test.jsx
@@ -0,0 +1,63 @@
+import React from 'react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { act, render } from '@testing-library/react';
+import PropTypes from 'prop-types';
+
+import ContentTagsCollapsible from './ContentTagsCollapsible';
+
+jest.mock('./data/apiHooks', () => ({
+ useTaxonomyTagsDataResponse: jest.fn(),
+ useIsTaxonomyTagsDataLoaded: jest.fn(),
+}));
+
+const data = {
+ id: 123,
+ name: 'Taxonomy 1',
+ contentTags: [
+ {
+ value: 'Tag 1',
+ lineage: ['Tag 1'],
+ },
+ {
+ value: 'Tag 2',
+ lineage: ['Tag 2'],
+ },
+ ],
+};
+
+const ContentTagsCollapsibleComponent = ({ taxonomyAndTagsData }) => (
+
+
+
+);
+
+ContentTagsCollapsibleComponent.propTypes = {
+ taxonomyAndTagsData: PropTypes.shape({
+ id: PropTypes.number,
+ name: PropTypes.string,
+ contentTags: PropTypes.arrayOf(PropTypes.shape({
+ value: PropTypes.string,
+ lineage: PropTypes.arrayOf(PropTypes.string),
+ })),
+ }).isRequired,
+};
+
+describe('', () => {
+ it('should render taxonomy tags data along content tags number badge', async () => {
+ await act(async () => {
+ const { container, getByText } = render();
+ expect(getByText('Taxonomy 1')).toBeInTheDocument();
+ expect(container.getElementsByClassName('badge').length).toBe(1);
+ expect(getByText('2')).toBeInTheDocument();
+ });
+ });
+
+ it('should render taxonomy tags data without tags number badge', async () => {
+ data.contentTags = [];
+ await act(async () => {
+ const { container, getByText } = render();
+ expect(getByText('Taxonomy 1')).toBeInTheDocument();
+ expect(container.getElementsByClassName('invisible').length).toBe(1);
+ });
+ });
+});
diff --git a/src/content-tags-drawer/ContentTagsDrawer.jsx b/src/content-tags-drawer/ContentTagsDrawer.jsx
new file mode 100644
index 0000000000..eeed39be1d
--- /dev/null
+++ b/src/content-tags-drawer/ContentTagsDrawer.jsx
@@ -0,0 +1,127 @@
+import React, { useMemo, useEffect } from 'react';
+import {
+ Container,
+ CloseButton,
+ Spinner,
+} from '@edx/paragon';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { useParams } from 'react-router-dom';
+import messages from './messages';
+import ContentTagsCollapsible from './ContentTagsCollapsible';
+import { extractOrgFromContentId } from './utils';
+import {
+ useContentTaxonomyTagsDataResponse,
+ useIsContentTaxonomyTagsDataLoaded,
+ useContentDataResponse,
+ useIsContentDataLoaded,
+} from './data/apiHooks';
+import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks';
+import Loading from '../generic/Loading';
+
+const ContentTagsDrawer = () => {
+ const intl = useIntl();
+ const { contentId } = useParams();
+
+ const org = extractOrgFromContentId(contentId);
+
+ const useContentData = () => {
+ const contentData = useContentDataResponse(contentId);
+ const isContentDataLoaded = useIsContentDataLoaded(contentId);
+ return { contentData, isContentDataLoaded };
+ };
+
+ const useContentTaxonomyTagsData = () => {
+ const contentTaxonomyTagsData = useContentTaxonomyTagsDataResponse(contentId);
+ const isContentTaxonomyTagsLoaded = useIsContentTaxonomyTagsDataLoaded(contentId);
+ return { contentTaxonomyTagsData, isContentTaxonomyTagsLoaded };
+ };
+
+ const useTaxonomyListData = () => {
+ const taxonomyListData = useTaxonomyListDataResponse(org);
+ const isTaxonomyListLoaded = useIsTaxonomyListDataLoaded(org);
+ return { taxonomyListData, isTaxonomyListLoaded };
+ };
+
+ const { contentData, isContentDataLoaded } = useContentData();
+ const { contentTaxonomyTagsData, isContentTaxonomyTagsLoaded } = useContentTaxonomyTagsData();
+ const { taxonomyListData, isTaxonomyListLoaded } = useTaxonomyListData();
+
+ const closeContentTagsDrawer = () => {
+ // "*" allows communication with any origin
+ window.parent.postMessage('closeManageTagsDrawer', '*');
+ };
+
+ useEffect(() => {
+ const handleEsc = (event) => {
+ /* Close drawer when ESC-key is pressed and selectable dropdown box not open */
+ const selectableBoxOpen = document.querySelector('[data-selectable-box="taxonomy-tags"]');
+ if (event.key === 'Escape' && !selectableBoxOpen) {
+ closeContentTagsDrawer();
+ }
+ };
+ document.addEventListener('keydown', handleEsc);
+
+ return () => {
+ document.removeEventListener('keydown', handleEsc);
+ };
+ }, []);
+
+ const taxonomies = useMemo(() => {
+ if (taxonomyListData && contentTaxonomyTagsData) {
+ // Initialize list of content tags in taxonomies to populate
+ const taxonomiesList = taxonomyListData.results.map((taxonomy) => {
+ // eslint-disable-next-line no-param-reassign
+ taxonomy.contentTags = [];
+ return taxonomy;
+ });
+
+ const contentTaxonomies = contentTaxonomyTagsData.taxonomies;
+
+ // eslint-disable-next-line array-callback-return
+ contentTaxonomies.map((contentTaxonomyTags) => {
+ const contentTaxonomy = taxonomiesList.find((taxonomy) => taxonomy.id === contentTaxonomyTags.taxonomyId);
+ if (contentTaxonomy) {
+ contentTaxonomy.contentTags = contentTaxonomyTags.tags;
+ }
+ });
+
+ return taxonomiesList;
+ }
+ return [];
+ }, [taxonomyListData, contentTaxonomyTagsData]);
+
+ return (
+
+
+
+ closeContentTagsDrawer()} data-testid="drawer-close-button" />
+ {intl.formatMessage(messages.headerSubtitle)}
+ { isContentDataLoaded
+ ? { contentData.displayName }
+ : (
+
+
+
+ )}
+
+
+
+ { isTaxonomyListLoaded && isContentTaxonomyTagsLoaded
+ ? taxonomies.map((data) => (
+
+
+
+
+ ))
+ : }
+
+
+
+ );
+};
+
+export default ContentTagsDrawer;
diff --git a/src/content-tags-drawer/ContentTagsDrawer.test.jsx b/src/content-tags-drawer/ContentTagsDrawer.test.jsx
new file mode 100644
index 0000000000..ad479b1569
--- /dev/null
+++ b/src/content-tags-drawer/ContentTagsDrawer.test.jsx
@@ -0,0 +1,186 @@
+import React from 'react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { act, render, fireEvent } from '@testing-library/react';
+
+import ContentTagsDrawer from './ContentTagsDrawer';
+import {
+ useContentTaxonomyTagsDataResponse,
+ useIsContentTaxonomyTagsDataLoaded,
+ useContentDataResponse,
+ useIsContentDataLoaded,
+} from './data/apiHooks';
+import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks';
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useParams: () => ({
+ contentId: 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab',
+ }),
+}));
+
+jest.mock('./data/apiHooks', () => ({
+ useContentTaxonomyTagsDataResponse: jest.fn(),
+ useIsContentTaxonomyTagsDataLoaded: jest.fn(),
+ useContentDataResponse: jest.fn(),
+ useIsContentDataLoaded: jest.fn(),
+ useTaxonomyTagsDataResponse: jest.fn(),
+ useIsTaxonomyTagsDataLoaded: jest.fn(),
+}));
+
+jest.mock('../taxonomy/data/apiHooks', () => ({
+ useTaxonomyListDataResponse: jest.fn(),
+ useIsTaxonomyListDataLoaded: jest.fn(),
+}));
+
+const RootWrapper = () => (
+
+
+
+);
+
+describe('', () => {
+ it('should render page and page title correctly', () => {
+ const { getByText } = render();
+ expect(getByText('Manage tags')).toBeInTheDocument();
+ });
+
+ it('shows spinner before the content data query is complete', async () => {
+ useIsContentDataLoaded.mockReturnValue(false);
+ await act(async () => {
+ const { getAllByRole } = render();
+ const spinner = getAllByRole('status')[0];
+ expect(spinner.textContent).toEqual('Loading'); // Uses
+ });
+ });
+
+ it('shows spinner before the taxonomy tags query is complete', async () => {
+ useIsTaxonomyListDataLoaded.mockReturnValue(false);
+ useIsContentTaxonomyTagsDataLoaded.mockReturnValue(false);
+ await act(async () => {
+ const { getAllByRole } = render();
+ const spinner = getAllByRole('status')[1];
+ expect(spinner.textContent).toEqual('Loading...'); // Uses
+ });
+ });
+
+ it('shows the content display name after the query is complete', async () => {
+ useIsContentDataLoaded.mockReturnValue(true);
+ useContentDataResponse.mockReturnValue({
+ displayName: 'Unit 1',
+ });
+ await act(async () => {
+ const { getByText } = render();
+ expect(getByText('Unit 1')).toBeInTheDocument();
+ });
+ });
+
+ it('shows the taxonomies data including tag numbers after the query is complete', async () => {
+ useIsTaxonomyListDataLoaded.mockReturnValue(true);
+ useIsContentTaxonomyTagsDataLoaded.mockReturnValue(true);
+ useContentTaxonomyTagsDataResponse.mockReturnValue({
+ taxonomies: [
+ {
+ name: 'Taxonomy 1',
+ taxonomyId: 123,
+ editable: true,
+ tags: [
+ {
+ value: 'Tag 1',
+ lineage: ['Tag 1'],
+ },
+ {
+ value: 'Tag 2',
+ lineage: ['Tag 2'],
+ },
+ ],
+ },
+ {
+ name: 'Taxonomy 2',
+ taxonomyId: 124,
+ editable: true,
+ tags: [
+ {
+ value: 'Tag 3',
+ lineage: ['Tag 3'],
+ },
+ ],
+ },
+ ],
+ });
+ useTaxonomyListDataResponse.mockReturnValue({
+ results: [{
+ id: 123,
+ name: 'Taxonomy 1',
+ description: 'This is a description 1',
+ }, {
+ id: 124,
+ name: 'Taxonomy 2',
+ description: 'This is a description 2',
+ }],
+ });
+ await act(async () => {
+ const { container, getByText } = render();
+ expect(getByText('Taxonomy 1')).toBeInTheDocument();
+ expect(getByText('Taxonomy 2')).toBeInTheDocument();
+ const tagCountBadges = container.getElementsByClassName('badge');
+ expect(tagCountBadges[0].textContent).toBe('2');
+ expect(tagCountBadges[1].textContent).toBe('1');
+ });
+ });
+
+ it('should call closeContentTagsDrawer when CloseButton is clicked', async () => {
+ const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
+
+ const { getByTestId } = render();
+
+ // Find the CloseButton element by its test ID and trigger a click event
+ const closeButton = getByTestId('drawer-close-button');
+ await act(async () => {
+ fireEvent.click(closeButton);
+ });
+
+ expect(postMessageSpy).toHaveBeenCalledWith('closeManageTagsDrawer', '*');
+
+ postMessageSpy.mockRestore();
+ });
+
+ it('should call closeContentTagsDrawer when Escape key is pressed and no selectable box is active', () => {
+ const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
+
+ const { container } = render();
+
+ act(() => {
+ fireEvent.keyDown(container, {
+ key: 'Escape',
+ });
+ });
+
+ expect(postMessageSpy).toHaveBeenCalledWith('closeManageTagsDrawer', '*');
+
+ postMessageSpy.mockRestore();
+ });
+
+ it('should not call closeContentTagsDrawer when Escape key is pressed and a selectable box is active', () => {
+ const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
+
+ const { container } = render();
+
+ // Simulate that the selectable box is open by adding an element with the data attribute
+ const selectableBox = document.createElement('div');
+ selectableBox.setAttribute('data-selectable-box', 'taxonomy-tags');
+ document.body.appendChild(selectableBox);
+
+ act(() => {
+ fireEvent.keyDown(container, {
+ key: 'Escape',
+ });
+ });
+
+ expect(postMessageSpy).not.toHaveBeenCalled();
+
+ // Remove the added element
+ document.body.removeChild(selectableBox);
+
+ postMessageSpy.mockRestore();
+ });
+});
diff --git a/src/content-tags-drawer/ContentTagsDropDownSelector.jsx b/src/content-tags-drawer/ContentTagsDropDownSelector.jsx
new file mode 100644
index 0000000000..e71ac8a80f
--- /dev/null
+++ b/src/content-tags-drawer/ContentTagsDropDownSelector.jsx
@@ -0,0 +1,95 @@
+import React, { useState } from 'react';
+import {
+ SelectableBox,
+ Icon,
+ Spinner,
+} from '@edx/paragon';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { ArrowDropDown, ArrowDropUp } from '@edx/paragon/icons';
+import PropTypes from 'prop-types';
+import messages from './messages';
+import './ContentTagsDropDownSelector.scss';
+
+import { useTaxonomyTagsDataResponse, useIsTaxonomyTagsDataLoaded } from './data/apiHooks';
+
+const ContentTagsDropDownSelector = ({
+ taxonomyId, level, subTagsUrl,
+}) => {
+ const intl = useIntl();
+ // This object represents the states of the dropdowns on this level
+ // The keys represent the index of the dropdown with
+ // the value true (open) false (closed)
+ const [dropdownStates, setDropdownStates] = useState({});
+
+ const isOpen = (i) => dropdownStates[i];
+
+ const clickAndEnterHandler = (i) => {
+ // This flips the state of the dropdown at index false (closed) -> true (open)
+ // and vice versa. Initially they are undefined which is falsy.
+ setDropdownStates({ ...dropdownStates, [i]: !dropdownStates[i] });
+ };
+
+ const taxonomyTagsData = useTaxonomyTagsDataResponse(taxonomyId, subTagsUrl);
+ const isTaxonomyTagsLoaded = useIsTaxonomyTagsDataLoaded(taxonomyId, subTagsUrl);
+
+ return (
+ isTaxonomyTagsLoaded && taxonomyTagsData
+ ? taxonomyTagsData.results.map((taxonomyTag, i) => (
+
+
+
+ {taxonomyTag.value}
+
+ { taxonomyTag.subTagsUrl
+ && (
+
+ clickAndEnterHandler(i)}
+ tabIndex="0"
+ onKeyPress={(event) => (event.key === 'Enter' ? clickAndEnterHandler(i) : null)}
+ />
+
+ )}
+
+
+ { taxonomyTag.subTagsUrl && isOpen(i) && (
+
+ )}
+
+
+ ))
+ : (
+
+
+
+ )
+ );
+};
+
+ContentTagsDropDownSelector.defaultProps = {
+ subTagsUrl: undefined,
+};
+
+ContentTagsDropDownSelector.propTypes = {
+ taxonomyId: PropTypes.number.isRequired,
+ level: PropTypes.number.isRequired,
+ subTagsUrl: PropTypes.string,
+};
+
+export default ContentTagsDropDownSelector;
diff --git a/src/content-tags-drawer/ContentTagsDropDownSelector.scss b/src/content-tags-drawer/ContentTagsDropDownSelector.scss
new file mode 100644
index 0000000000..33c29517e8
--- /dev/null
+++ b/src/content-tags-drawer/ContentTagsDropDownSelector.scss
@@ -0,0 +1,8 @@
+.taxonomy-tags-arrow-drop-down {
+ cursor: pointer;
+}
+
+.pgn__selectable_box.taxonomy-tags-selectable-box {
+ box-shadow: none;
+ padding: 0;
+}
diff --git a/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx b/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx
new file mode 100644
index 0000000000..80ca659632
--- /dev/null
+++ b/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx
@@ -0,0 +1,96 @@
+import React from 'react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { act, render } from '@testing-library/react';
+import PropTypes from 'prop-types';
+
+import ContentTagsDropDownSelector from './ContentTagsDropDownSelector';
+import { useTaxonomyTagsDataResponse, useIsTaxonomyTagsDataLoaded } from './data/apiHooks';
+
+jest.mock('./data/apiHooks', () => ({
+ useTaxonomyTagsDataResponse: jest.fn(),
+ useIsTaxonomyTagsDataLoaded: jest.fn(),
+}));
+
+const data = {
+ taxonomyId: 123,
+ level: 0,
+};
+
+const TaxonomyTagsDropDownSelectorComponent = ({
+ taxonomyId, level, subTagsUrl,
+}) => (
+
+
+
+);
+
+TaxonomyTagsDropDownSelectorComponent.defaultProps = {
+ subTagsUrl: undefined,
+};
+
+TaxonomyTagsDropDownSelectorComponent.propTypes = {
+ taxonomyId: PropTypes.number.isRequired,
+ level: PropTypes.number.isRequired,
+ subTagsUrl: PropTypes.string,
+};
+
+describe('', () => {
+ it('should render taxonomy tags drop down selector loading with spinner', async () => {
+ useIsTaxonomyTagsDataLoaded.mockReturnValue(false);
+ await act(async () => {
+ const { getByRole } = render(
+ ,
+ );
+ const spinner = getByRole('status');
+ expect(spinner.textContent).toEqual('Loading tags'); // Uses
+ });
+ });
+
+ it('should render taxonomy tags drop down selector with no sub tags', async () => {
+ useIsTaxonomyTagsDataLoaded.mockReturnValue(true);
+ useTaxonomyTagsDataResponse.mockReturnValue({
+ results: [{
+ value: 'Tag 1',
+ subTagsUrl: null,
+ }],
+ });
+ await act(async () => {
+ const { container, getByText } = render(
+ ,
+ );
+ expect(getByText('Tag 1')).toBeInTheDocument();
+ expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(0);
+ });
+ });
+
+ it('should render taxonomy tags drop down selector with sub tags', async () => {
+ useIsTaxonomyTagsDataLoaded.mockReturnValue(true);
+ useTaxonomyTagsDataResponse.mockReturnValue({
+ results: [{
+ value: 'Tag 2',
+ subTagsUrl: 'https://example.com',
+ }],
+ });
+ await act(async () => {
+ const { container, getByText } = render(
+ ,
+ );
+ expect(getByText('Tag 2')).toBeInTheDocument();
+ expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
+ });
+ });
+});
diff --git a/src/content-tags-drawer/ContentTagsTree.jsx b/src/content-tags-drawer/ContentTagsTree.jsx
new file mode 100644
index 0000000000..e75ead4766
--- /dev/null
+++ b/src/content-tags-drawer/ContentTagsTree.jsx
@@ -0,0 +1,88 @@
+import React, { useMemo } from 'react';
+import PropTypes from 'prop-types';
+
+import TagBubble from './TagBubble';
+
+/**
+ * Component that renders Tags under a Taxonomy in the nested tree format
+ * It constructs a tree structure consolidating the tag data. Example:
+ *
+ * FROM:
+ *
+ * [
+ * {
+ * "value": "DNA Sequencing",
+ * "lineage": [
+ * "Science and Research",
+ * "Genetics Subcategory",
+ * "DNA Sequencing"
+ * ]
+ * },
+ * {
+ * "value": "Virology",
+ * "lineage": [
+ * "Science and Research",
+ * "Molecular, Cellular, and Microbiology",
+ * "Virology"
+ * ]
+ * }
+ * ]
+ *
+ * TO:
+ *
+ * {
+ * "Science and Research": {
+ * "Genetics Subcategory": {
+ * "DNA Sequencing": {}
+ * },
+ * "Molecular, Cellular, and Microbiology": {
+ * "Virology": {}
+ * }
+ * }
+ * }
+ *
+ * @param {Object[]} appliedContentTags - Array of taxonomy tags that are applied to the content
+ * @param {string} appliedContentTags.value - Value of applied Tag
+ * @param {string} appliedContentTags.lineage - Array of Tag's ancestors sorted (ancestor -> tag)
+ */
+const ContentTagsTree = ({ appliedContentTags }) => {
+ const tagsTree = useMemo(() => {
+ const tree = {};
+ appliedContentTags.forEach(tag => {
+ tag.lineage.reduce((currentLevel, ancestor) => {
+ // eslint-disable-next-line no-param-reassign
+ currentLevel[ancestor] = currentLevel[ancestor] || {};
+ return currentLevel[ancestor];
+ }, tree);
+ });
+ return tree;
+ }, [appliedContentTags]);
+
+ const renderTagsTree = (tag, level) => Object.keys(tag).map((key) => {
+ if (tag[key] !== undefined) {
+ return (
+
+
+ { renderTagsTree(tag[key], level + 1) }
+
+ );
+ }
+ return null;
+ });
+
+ return renderTagsTree(tagsTree, 0);
+};
+
+ContentTagsTree.propTypes = {
+ appliedContentTags: PropTypes.arrayOf(PropTypes.shape({
+ value: PropTypes.string,
+ lineage: PropTypes.arrayOf(PropTypes.string),
+ })).isRequired,
+};
+
+export default ContentTagsTree;
diff --git a/src/content-tags-drawer/ContentTagsTree.test.jsx b/src/content-tags-drawer/ContentTagsTree.test.jsx
new file mode 100644
index 0000000000..dd28cc9a98
--- /dev/null
+++ b/src/content-tags-drawer/ContentTagsTree.test.jsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { act, render } from '@testing-library/react';
+import PropTypes from 'prop-types';
+
+import ContentTagsTree from './ContentTagsTree';
+
+const data = [
+ {
+ value: 'DNA Sequencing',
+ lineage: [
+ 'Science and Research',
+ 'Genetics Subcategory',
+ 'DNA Sequencing',
+ ],
+ },
+ {
+ value: 'Virology',
+ lineage: [
+ 'Science and Research',
+ 'Molecular, Cellular, and Microbiology',
+ 'Virology',
+ ],
+ },
+];
+
+const ContentTagsTreeComponent = ({ appliedContentTags }) => (
+
+
+
+);
+
+ContentTagsTreeComponent.propTypes = {
+ appliedContentTags: PropTypes.arrayOf(PropTypes.shape({
+ value: PropTypes.string,
+ lineage: PropTypes.arrayOf(PropTypes.string),
+ })).isRequired,
+};
+
+describe('', () => {
+ it('should render taxonomy tags data along content tags number badge', async () => {
+ await act(async () => {
+ const { getByText } = render();
+ expect(getByText('Science and Research')).toBeInTheDocument();
+ expect(getByText('Genetics Subcategory')).toBeInTheDocument();
+ expect(getByText('Molecular, Cellular, and Microbiology')).toBeInTheDocument();
+ expect(getByText('DNA Sequencing')).toBeInTheDocument();
+ expect(getByText('Virology')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/content-tags-drawer/TagBubble.jsx b/src/content-tags-drawer/TagBubble.jsx
new file mode 100644
index 0000000000..8c7137ffa0
--- /dev/null
+++ b/src/content-tags-drawer/TagBubble.jsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import {
+ Button,
+} from '@edx/paragon';
+import { Tag, Close } from '@edx/paragon/icons';
+import PropTypes from 'prop-types';
+
+import TagOutlineIcon from './TagOutlineIcon';
+
+const TagBubble = ({
+ value, subTagsCount, implicit, level,
+}) => {
+ const className = `tag-bubble mb-2 ${implicit ? 'implicit' : ''}`;
+ const tagIcon = () => (implicit ? : );
+ return (
+
+
+
+ );
+};
+
+TagBubble.defaultProps = {
+ subTagsCount: 0,
+ implicit: true,
+ level: 0,
+};
+
+TagBubble.propTypes = {
+ value: PropTypes.string.isRequired,
+ subTagsCount: PropTypes.number,
+ implicit: PropTypes.bool,
+ level: PropTypes.number,
+};
+
+export default TagBubble;
diff --git a/src/content-tags-drawer/TagBubble.scss b/src/content-tags-drawer/TagBubble.scss
new file mode 100644
index 0000000000..281d0fe209
--- /dev/null
+++ b/src/content-tags-drawer/TagBubble.scss
@@ -0,0 +1,13 @@
+.tag-bubble.btn-outline-dark {
+ border-color: $light-300;
+
+ &:hover {
+ color: $white;
+ background-color: $dark;
+ border-color: $dark;
+ }
+}
+
+.implicit > .implicit-tag-icon {
+ color: $dark;
+}
diff --git a/src/content-tags-drawer/TagBubble.test.jsx b/src/content-tags-drawer/TagBubble.test.jsx
new file mode 100644
index 0000000000..90ba32f288
--- /dev/null
+++ b/src/content-tags-drawer/TagBubble.test.jsx
@@ -0,0 +1,66 @@
+import React from 'react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { render } from '@testing-library/react';
+import PropTypes from 'prop-types';
+
+import TagBubble from './TagBubble';
+
+const data = {
+ value: 'Tag 1',
+};
+
+const TagBubbleComponent = ({ value, subTagsCount, implicit }) => (
+
+
+
+);
+
+TagBubbleComponent.defaultProps = {
+ subTagsCount: 0,
+ implicit: true,
+};
+
+TagBubbleComponent.propTypes = {
+ value: PropTypes.string.isRequired,
+ subTagsCount: PropTypes.number,
+ implicit: PropTypes.bool,
+};
+
+describe('', () => {
+ it('should render only value of the implicit tag with no sub tags', () => {
+ const { container, getByText } = render();
+ expect(getByText(data.value)).toBeInTheDocument();
+ expect(container.getElementsByClassName('implicit').length).toBe(1);
+ });
+
+ it('should render value of the implicit tag with sub tags', () => {
+ const tagBubbleData = {
+ subTagsCount: 5,
+ ...data,
+ };
+ const { container, getByText } = render(
+ ,
+ );
+ expect(getByText(`${tagBubbleData.value} (${tagBubbleData.subTagsCount})`)).toBeInTheDocument();
+ expect(container.getElementsByClassName('implicit').length).toBe(1);
+ });
+
+ it('should render value of the explicit tag with no sub tags', () => {
+ const tagBubbleData = {
+ implicit: false,
+ ...data,
+ };
+ const { container, getByText } = render(
+ ,
+ );
+ expect(getByText(`${tagBubbleData.value}`)).toBeInTheDocument();
+ expect(container.getElementsByClassName('implicit').length).toBe(0);
+ expect(container.getElementsByClassName('btn-icon-after').length).toBe(1);
+ });
+});
diff --git a/src/content-tags-drawer/TagOutlineIcon.jsx b/src/content-tags-drawer/TagOutlineIcon.jsx
new file mode 100644
index 0000000000..f817b1f077
--- /dev/null
+++ b/src/content-tags-drawer/TagOutlineIcon.jsx
@@ -0,0 +1,21 @@
+const TagOutlineIcon = (props) => (
+
+);
+
+export default TagOutlineIcon;
diff --git a/src/content-tags-drawer/__mocks__/contentDataMock.js b/src/content-tags-drawer/__mocks__/contentDataMock.js
new file mode 100644
index 0000000000..292efc38d0
--- /dev/null
+++ b/src/content-tags-drawer/__mocks__/contentDataMock.js
@@ -0,0 +1,63 @@
+module.exports = {
+ id: 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab',
+ displayName: 'Unit 1.1.2',
+ category: 'vertical',
+ hasChildren: true,
+ editedOn: 'Nov 12, 2023 at 09:53 UTC',
+ published: false,
+ publishedOn: null,
+ studioUrl: '/container/block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab',
+ releasedToStudents: false,
+ releaseDate: null,
+ visibilityState: 'needs_attention',
+ hasExplicitStaffLock: false,
+ start: '2030-01-01T00:00:00Z',
+ graded: false,
+ dueDate: '',
+ due: null,
+ relativeWeeksDue: null,
+ format: null,
+ courseGraders: [
+ 'Homework',
+ 'Lab',
+ 'Midterm Exam',
+ 'Final Exam',
+ ],
+ hasChanges: true,
+ actions: {
+ deletable: true,
+ draggable: true,
+ childAddable: true,
+ duplicable: true,
+ },
+ explanatoryMessage: null,
+ groupAccess: {},
+ userPartitions: [
+ {
+ id: 50,
+ name: 'Enrollment Track Groups',
+ scheme: 'enrollment_track',
+ groups: [
+ {
+ id: 1,
+ name: 'Audit',
+ selected: false,
+ deleted: false,
+ },
+ ],
+ },
+ ],
+ showCorrectness: 'always',
+ discussionEnabled: true,
+ ancestorHasStaffLock: false,
+ taxonomyTagsWidgetUrl: 'http://localhost:2001/tagging/components/widget/',
+ staffOnlyMessage: false,
+ enableCopyPasteUnits: true,
+ useTaggingTaxonomyListPage: true,
+ hasPartitionGroupComponents: false,
+ userPartitionInfo: {
+ selectablePartitions: [],
+ selectedPartitionIndex: -1,
+ selectedGroupsLabel: '',
+ },
+};
diff --git a/src/content-tags-drawer/__mocks__/contentTaxonomyTagsMock.js b/src/content-tags-drawer/__mocks__/contentTaxonomyTagsMock.js
new file mode 100644
index 0000000000..2e8aa0bea7
--- /dev/null
+++ b/src/content-tags-drawer/__mocks__/contentTaxonomyTagsMock.js
@@ -0,0 +1,50 @@
+module.exports = {
+ 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b': {
+ taxonomies: [
+ {
+ name: 'FlatTaxonomy',
+ taxonomyId: 3,
+ editable: true,
+ tags: [
+ {
+ value: 'flat taxonomy tag 3856',
+ lineage: [
+ 'flat taxonomy tag 3856',
+ ],
+ },
+ ],
+ },
+ {
+ name: 'HierarchicalTaxonomy',
+ taxonomyId: 4,
+ editable: true,
+ tags: [
+ {
+ value: 'hierarchical taxonomy tag 1.7.59',
+ lineage: [
+ 'hierarchical taxonomy tag 1',
+ 'hierarchical taxonomy tag 1.7',
+ 'hierarchical taxonomy tag 1.7.59',
+ ],
+ },
+ {
+ value: 'hierarchical taxonomy tag 2.13.46',
+ lineage: [
+ 'hierarchical taxonomy tag 2',
+ 'hierarchical taxonomy tag 2.13',
+ 'hierarchical taxonomy tag 2.13.46',
+ ],
+ },
+ {
+ value: 'hierarchical taxonomy tag 3.4.50',
+ lineage: [
+ 'hierarchical taxonomy tag 3',
+ 'hierarchical taxonomy tag 3.4',
+ 'hierarchical taxonomy tag 3.4.50',
+ ],
+ },
+ ],
+ },
+ ],
+ },
+};
diff --git a/src/content-tags-drawer/__mocks__/index.js b/src/content-tags-drawer/__mocks__/index.js
new file mode 100644
index 0000000000..b09fc5d3ab
--- /dev/null
+++ b/src/content-tags-drawer/__mocks__/index.js
@@ -0,0 +1,3 @@
+export { default as taxonomyTagsMock } from './taxonomyTagsMock';
+export { default as contentTaxonomyTagsMock } from './contentTaxonomyTagsMock';
+export { default as contentDataMock } from './contentDataMock';
diff --git a/src/content-tags-drawer/__mocks__/taxonomyTagsMock.js b/src/content-tags-drawer/__mocks__/taxonomyTagsMock.js
new file mode 100644
index 0000000000..0b2bc714c8
--- /dev/null
+++ b/src/content-tags-drawer/__mocks__/taxonomyTagsMock.js
@@ -0,0 +1,46 @@
+module.exports = {
+ next: null,
+ previous: null,
+ count: 4,
+ numPages: 1,
+ currentPage: 1,
+ start: 0,
+ results: [
+ {
+ value: 'tag 1',
+ externalId: null,
+ childCount: 16,
+ depth: 0,
+ parentValue: null,
+ id: 635951,
+ subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%201',
+ },
+ {
+ value: 'tag 2',
+ externalId: null,
+ childCount: 16,
+ depth: 0,
+ parentValue: null,
+ id: 636992,
+ subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%202',
+ },
+ {
+ value: 'tag 3',
+ externalId: null,
+ childCount: 16,
+ depth: 0,
+ parentValue: null,
+ id: 638033,
+ subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%203',
+ },
+ {
+ value: 'tag 4',
+ externalId: null,
+ childCount: 16,
+ depth: 0,
+ parentValue: null,
+ id: 639074,
+ subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%204',
+ },
+ ],
+};
diff --git a/src/content-tags-drawer/data/api.js b/src/content-tags-drawer/data/api.js
new file mode 100644
index 0000000000..e63b3d0842
--- /dev/null
+++ b/src/content-tags-drawer/data/api.js
@@ -0,0 +1,42 @@
+// @ts-check
+import { camelCaseObject, getConfig } from '@edx/frontend-platform';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+
+const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
+export const getTaxonomyTagsApiUrl = (taxonomyId) => new URL(`api/content_tagging/v1/taxonomies/${taxonomyId}/tags/`, getApiBaseUrl()).href;
+export const getContentTaxonomyTagsApiUrl = (contentId) => new URL(`api/content_tagging/v1/object_tags/${contentId}/`, getApiBaseUrl()).href;
+export const getContentDataApiUrl = (contentId) => new URL(`/xblock/outline/${contentId}`, getApiBaseUrl()).href;
+
+/**
+ * Get all tags that belong to taxonomy.
+ * @param {string} taxonomyId The id of the taxonomy to fetch tags for
+ * @param {string} fullPathProvided Optional param that contains the full URL to fetch data
+ * If provided, we use it instead of generating the URL. This is usually for fetching subTags
+ * @returns {Promise