diff --git a/src/content-tags-drawer/ContentTagsCollapsible.jsx b/src/content-tags-drawer/ContentTagsCollapsible.jsx
index 5e27f8291c..2337af8db2 100644
--- a/src/content-tags-drawer/ContentTagsCollapsible.jsx
+++ b/src/content-tags-drawer/ContentTagsCollapsible.jsx
@@ -17,10 +17,79 @@ import ContentTagsDropDownSelector from './ContentTagsDropDownSelector';
import ContentTagsTree from './ContentTagsTree';
+import useContentTagsCollapsibleHelper from './ContentTagsCollapsibleHelper';
+
/**
* 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.
+ *
+ * This component also handles all the logic with selecting/deselecting tags and keeps track of the
+ * tags tree in the state. That is used to render the Tag bubbgles as well as the populating the
+ * state of the tags in the dropdown selectors.
+ *
+ * The `contentTags` that is passed are consolidated and converted to a tree structure. For 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": {
+ * explicit: false,
+ * children: {
+ * "Genetics Subcategory": {
+ * explicit: false,
+ * children: {
+ * "DNA Sequencing": {
+ * explicit: true,
+ * children: {}
+ * }
+ * }
+ * },
+ * "Molecular, Cellular, and Microbiology": {
+ * explicit: false,
+ * children: {
+ * "Virology": {
+ * explicit: true,
+ * children: {}
+ * }
+ * }
+ * }
+ * }
+ * }
+ * };
+ *
+ *
+ * It also keeps track of newly added tags as they are selected in the dropdown selectors.
+ * They are store in the same format above, and then merged to one tree that is used as the
+ * source of truth for both the tag bubble and the dropdowns. They keys are order alphabetically.
+ *
+ * In the dropdowns, the value of each SelectableBox is stored along with it's lineage and is URI encoded.
+ * Ths is so we are able to traverse and manipulate different parts of the tree leading to it.
+ * Here is an example of what the value of the "Virology" tag would be:
+ *
+ * "Science%20and%20Research,Molecular%2C%20Cellular%2C%20and%20Microbiology,Virology"
+ * @param {string} contentId - Id of the content object
* @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
@@ -35,36 +104,46 @@ import ContentTagsTree from './ContentTagsTree';
* @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)
+ * @param {boolean} editable - Whether the tags can be edited
*/
-const ContentTagsCollapsible = ({ taxonomyAndTagsData }) => {
+const ContentTagsCollapsible = ({ contentId, taxonomyAndTagsData, editable }) => {
const intl = useIntl();
+ const { id, name } = taxonomyAndTagsData;
+
const {
- id, name, contentTags,
- } = taxonomyAndTagsData;
+ tagChangeHandler, tagsTree, contentTagsCount, checkedTags,
+ } = useContentTagsCollapsibleHelper(contentId, taxonomyAndTagsData);
const [isOpen, open, close] = useToggle(false);
- const [target, setTarget] = React.useState(null);
+ const [addTagsButtonRef, setAddTagsButtonRef] = React.useState(null);
+
+ const handleSelectableBoxChange = React.useCallback((e) => {
+ tagChangeHandler(e.target.value, e.target.checked);
+ });
return (
{taxonomyTag.value}
@@ -65,12 +103,27 @@ const ContentTagsDropDownSelector = ({
taxonomyId={taxonomyId}
subTagsUrl={taxonomyTag.subTagsUrl}
level={level + 1}
+ lineage={[...lineage, encodeURIComponent(taxonomyTag.value)]}
+ tagsTree={tagsTree}
/>
)}
- ))
- : (
+ ))}
+
+ { nextPage && isTaxonomyTagsLoaded
+ ? (
+
+
+
+ )
+ : null}
+
+ { !isTaxonomyTagsLoaded ? (
- )
+ ) : null}
+ >
);
};
ContentTagsDropDownSelector.defaultProps = {
subTagsUrl: undefined,
+ lineage: [],
};
ContentTagsDropDownSelector.propTypes = {
taxonomyId: PropTypes.number.isRequired,
level: PropTypes.number.isRequired,
subTagsUrl: PropTypes.string,
+ lineage: PropTypes.arrayOf(PropTypes.string),
+ tagsTree: PropTypes.objectOf(
+ PropTypes.shape({
+ explicit: PropTypes.bool.isRequired,
+ children: PropTypes.shape({}).isRequired,
+ }).isRequired,
+ ).isRequired,
};
export default ContentTagsDropDownSelector;
diff --git a/src/content-tags-drawer/ContentTagsDropDownSelector.scss b/src/content-tags-drawer/ContentTagsDropDownSelector.scss
index 33c29517e8..a6b72affc9 100644
--- a/src/content-tags-drawer/ContentTagsDropDownSelector.scss
+++ b/src/content-tags-drawer/ContentTagsDropDownSelector.scss
@@ -6,3 +6,12 @@
box-shadow: none;
padding: 0;
}
+
+.pgn__selectable_box.taxonomy-tags-selectable-box:disabled,
+.pgn__selectable_box.taxonomy-tags-selectable-box[disabled] {
+ opacity: 1 !important;
+}
+
+.pgn__selectable_box-active.taxonomy-tags-selectable-box {
+ outline: none !important;
+}
diff --git a/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx b/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx
index 80ca659632..5823feaac4 100644
--- a/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx
+++ b/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx
@@ -1,51 +1,64 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
-import { act, render } from '@testing-library/react';
+import { act, render, waitFor } from '@testing-library/react';
import PropTypes from 'prop-types';
import ContentTagsDropDownSelector from './ContentTagsDropDownSelector';
-import { useTaxonomyTagsDataResponse, useIsTaxonomyTagsDataLoaded } from './data/apiHooks';
+import { useTaxonomyTagsData } from './data/apiHooks';
jest.mock('./data/apiHooks', () => ({
- useTaxonomyTagsDataResponse: jest.fn(),
- useIsTaxonomyTagsDataLoaded: jest.fn(),
+ useTaxonomyTagsData: jest.fn(() => ({
+ isSuccess: false,
+ data: {},
+ })),
}));
const data = {
taxonomyId: 123,
level: 0,
+ tagsTree: {},
};
-const TaxonomyTagsDropDownSelectorComponent = ({
- taxonomyId, level, subTagsUrl,
+const ContentTagsDropDownSelectorComponent = ({
+ taxonomyId, level, subTagsUrl, lineage, tagsTree,
}) => (
);
-TaxonomyTagsDropDownSelectorComponent.defaultProps = {
+ContentTagsDropDownSelectorComponent.defaultProps = {
subTagsUrl: undefined,
+ lineage: [],
};
-TaxonomyTagsDropDownSelectorComponent.propTypes = {
+ContentTagsDropDownSelectorComponent.propTypes = {
taxonomyId: PropTypes.number.isRequired,
level: PropTypes.number.isRequired,
subTagsUrl: PropTypes.string,
+ lineage: PropTypes.arrayOf(PropTypes.string),
+ tagsTree: PropTypes.objectOf(
+ PropTypes.shape({
+ explicit: PropTypes.bool.isRequired,
+ children: PropTypes.shape({}).isRequired,
+ }).isRequired,
+ ).isRequired,
};
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');
@@ -54,43 +67,53 @@ describe('
', () => {
});
it('should render taxonomy tags drop down selector with no sub tags', async () => {
- useIsTaxonomyTagsDataLoaded.mockReturnValue(true);
- useTaxonomyTagsDataResponse.mockReturnValue({
- results: [{
- value: 'Tag 1',
- subTagsUrl: null,
- }],
+ useTaxonomyTagsData.mockReturnValue({
+ isSuccess: true,
+ data: {
+ 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);
+ await waitFor(() => {
+ 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',
- }],
+ useTaxonomyTagsData.mockReturnValue({
+ isSuccess: true,
+ data: {
+ 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);
+ await waitFor(() => {
+ 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
index e75ead4766..08ae7e145f 100644
--- a/src/content-tags-drawer/ContentTagsTree.jsx
+++ b/src/content-tags-drawer/ContentTagsTree.jsx
@@ -1,88 +1,77 @@
-import React, { useMemo } from 'react';
+import React 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:
+ * Component that renders Tags under a Taxonomy in the nested tree format.
*
- * FROM:
- *
- * [
- * {
- * "value": "DNA Sequencing",
- * "lineage": [
- * "Science and Research",
- * "Genetics Subcategory",
- * "DNA Sequencing"
- * ]
- * },
- * {
- * "value": "Virology",
- * "lineage": [
- * "Science and Research",
- * "Molecular, Cellular, and Microbiology",
- * "Virology"
- * ]
- * }
- * ]
- *
- * TO:
+ * Example:
*
* {
* "Science and Research": {
- * "Genetics Subcategory": {
- * "DNA Sequencing": {}
- * },
- * "Molecular, Cellular, and Microbiology": {
- * "Virology": {}
+ * explicit: false,
+ * children: {
+ * "Genetics Subcategory": {
+ * explicit: false,
+ * children: {
+ * "DNA Sequencing": {
+ * explicit: true,
+ * children: {}
+ * }
+ * }
+ * },
+ * "Molecular, Cellular, and Microbiology": {
+ * explicit: false,
+ * children: {
+ * "Virology": {
+ * explicit: true,
+ * children: {}
+ * }
+ * }
+ * }
* }
* }
- * }
+ * };
*
- * @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)
+ * @param {Object} tagsTree - Array of taxonomy tags that are applied to the content
+ * @param {Func} removeTagHandler - Function that is called when removing tags from tree
+ * @param {boolean} editable - Whether the tags appear with an 'x' allowing the user to remove them
*/
-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) => {
+const ContentTagsTree = ({ tagsTree, removeTagHandler, editable }) => {
+ const renderTagsTree = (tag, level, lineage) => Object.keys(tag).map((key) => {
+ const updatedLineage = [...lineage, encodeURIComponent(key)];
if (tag[key] !== undefined) {
return (
- { renderTagsTree(tag[key], level + 1) }
+ { renderTagsTree(tag[key].children, level + 1, updatedLineage) }
);
}
return null;
});
- return renderTagsTree(tagsTree, 0);
+ return renderTagsTree(tagsTree, 0, []);
};
ContentTagsTree.propTypes = {
- appliedContentTags: PropTypes.arrayOf(PropTypes.shape({
- value: PropTypes.string,
- lineage: PropTypes.arrayOf(PropTypes.string),
- })).isRequired,
+ tagsTree: PropTypes.objectOf(
+ PropTypes.shape({
+ explicit: PropTypes.bool.isRequired,
+ children: PropTypes.shape({}).isRequired,
+ }).isRequired,
+ ).isRequired,
+ removeTagHandler: PropTypes.func.isRequired,
+ editable: PropTypes.bool.isRequired,
};
export default ContentTagsTree;
diff --git a/src/content-tags-drawer/ContentTagsTree.test.jsx b/src/content-tags-drawer/ContentTagsTree.test.jsx
index dd28cc9a98..ac41f3c1f1 100644
--- a/src/content-tags-drawer/ContentTagsTree.test.jsx
+++ b/src/content-tags-drawer/ContentTagsTree.test.jsx
@@ -5,42 +5,53 @@ import PropTypes from 'prop-types';
import ContentTagsTree from './ContentTagsTree';
-const data = [
- {
- value: 'DNA Sequencing',
- lineage: [
- 'Science and Research',
- 'Genetics Subcategory',
- 'DNA Sequencing',
- ],
+const data = {
+ 'Science and Research': {
+ explicit: false,
+ children: {
+ 'Genetics Subcategory': {
+ explicit: false,
+ children: {
+ 'DNA Sequencing': {
+ explicit: true,
+ children: {},
+ },
+ },
+ },
+ 'Molecular, Cellular, and Microbiology': {
+ explicit: false,
+ children: {
+ Virology: {
+ explicit: true,
+ children: {},
+ },
+ },
+ },
+ },
},
- {
- value: 'Virology',
- lineage: [
- 'Science and Research',
- 'Molecular, Cellular, and Microbiology',
- 'Virology',
- ],
- },
-];
+};
-const ContentTagsTreeComponent = ({ appliedContentTags }) => (
+const ContentTagsTreeComponent = ({ tagsTree, removeTagHandler, editable }) => (
-
+
);
ContentTagsTreeComponent.propTypes = {
- appliedContentTags: PropTypes.arrayOf(PropTypes.shape({
- value: PropTypes.string,
- lineage: PropTypes.arrayOf(PropTypes.string),
- })).isRequired,
+ tagsTree: PropTypes.objectOf(
+ PropTypes.shape({
+ explicit: PropTypes.bool.isRequired,
+ children: PropTypes.shape({}).isRequired,
+ }).isRequired,
+ ).isRequired,
+ removeTagHandler: PropTypes.func.isRequired,
+ editable: PropTypes.bool.isRequired,
};
describe('
', () => {
it('should render taxonomy tags data along content tags number badge', async () => {
await act(async () => {
- const { getByText } = render(
);
+ const { getByText } = render(
{}} editable />);
expect(getByText('Science and Research')).toBeInTheDocument();
expect(getByText('Genetics Subcategory')).toBeInTheDocument();
expect(getByText('Molecular, Cellular, and Microbiology')).toBeInTheDocument();
diff --git a/src/content-tags-drawer/TagBubble.jsx b/src/content-tags-drawer/TagBubble.jsx
index 8c7137ffa0..2287e48ab3 100644
--- a/src/content-tags-drawer/TagBubble.jsx
+++ b/src/content-tags-drawer/TagBubble.jsx
@@ -1,6 +1,6 @@
import React from 'react';
import {
- Button,
+ Chip,
} from '@edx/paragon';
import { Tag, Close } from '@edx/paragon/icons';
import PropTypes from 'prop-types';
@@ -8,35 +8,43 @@ import PropTypes from 'prop-types';
import TagOutlineIcon from './TagOutlineIcon';
const TagBubble = ({
- value, subTagsCount, implicit, level,
+ value, implicit, level, lineage, removeTagHandler, editable,
}) => {
- const className = `tag-bubble mb-2 ${implicit ? 'implicit' : ''}`;
- const tagIcon = () => (implicit ? : );
+ const className = `tag-bubble mb-2 border-light-300 ${implicit ? 'implicit' : ''}`;
+
+ const handleClick = React.useCallback(() => {
+ if (!implicit && editable) {
+ removeTagHandler(lineage.join(','), false);
+ }
+ }, [implicit, lineage, editable, removeTagHandler]);
+
return (
-
- {value} {subTagsCount > 0 ? `(${subTagsCount})` : null }
-
+ {value}
+
);
};
TagBubble.defaultProps = {
- subTagsCount: 0,
implicit: true,
level: 0,
};
TagBubble.propTypes = {
value: PropTypes.string.isRequired,
- subTagsCount: PropTypes.number,
implicit: PropTypes.bool,
level: PropTypes.number,
+ lineage: PropTypes.arrayOf(PropTypes.string).isRequired,
+ removeTagHandler: PropTypes.func.isRequired,
+ editable: PropTypes.bool.isRequired,
};
export default TagBubble;
diff --git a/src/content-tags-drawer/TagBubble.scss b/src/content-tags-drawer/TagBubble.scss
index 281d0fe209..c64d80d342 100644
--- a/src/content-tags-drawer/TagBubble.scss
+++ b/src/content-tags-drawer/TagBubble.scss
@@ -1,13 +1,5 @@
-.tag-bubble.btn-outline-dark {
- border-color: $light-300;
-
- &:hover {
- color: $white;
- background-color: $dark;
- border-color: $dark;
- }
-}
-
-.implicit > .implicit-tag-icon {
- color: $dark;
+.tag-bubble.pgn__chip {
+ border-style: solid;
+ border-width: 2px;
+ background-color: transparent;
}
diff --git a/src/content-tags-drawer/TagBubble.test.jsx b/src/content-tags-drawer/TagBubble.test.jsx
index 90ba32f288..48fe71ecfd 100644
--- a/src/content-tags-drawer/TagBubble.test.jsx
+++ b/src/content-tags-drawer/TagBubble.test.jsx
@@ -1,66 +1,98 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
-import { render } from '@testing-library/react';
+import { act, render, fireEvent } from '@testing-library/react';
import PropTypes from 'prop-types';
import TagBubble from './TagBubble';
const data = {
value: 'Tag 1',
+ lineage: [],
+ removeTagHandler: jest.fn(),
};
-const TagBubbleComponent = ({ value, subTagsCount, implicit }) => (
+const TagBubbleComponent = ({
+ value, implicit, level, lineage, removeTagHandler, editable,
+}) => (
-
+
);
TagBubbleComponent.defaultProps = {
- subTagsCount: 0,
implicit: true,
+ level: 0,
};
TagBubbleComponent.propTypes = {
value: PropTypes.string.isRequired,
- subTagsCount: PropTypes.number,
implicit: PropTypes.bool,
+ level: PropTypes.number,
+ lineage: PropTypes.arrayOf(PropTypes.string).isRequired,
+ removeTagHandler: PropTypes.func.isRequired,
+ editable: PropTypes.bool.isRequired,
};
describe(' ', () => {
- it('should render only value of the implicit tag with no sub tags', () => {
- const { container, getByText } = render( );
+ it('should render implicit tag', () => {
+ const { container, getByText } = render(
+ ,
+ );
expect(getByText(data.value)).toBeInTheDocument();
expect(container.getElementsByClassName('implicit').length).toBe(1);
+ expect(container.getElementsByClassName('pgn__chip__icon-after').length).toBe(0);
});
- it('should render value of the implicit tag with sub tags', () => {
+ it('should render explicit tag', () => {
const tagBubbleData = {
- subTagsCount: 5,
+ implicit: false,
...data,
};
const { container, getByText } = render(
,
);
- expect(getByText(`${tagBubbleData.value} (${tagBubbleData.subTagsCount})`)).toBeInTheDocument();
- expect(container.getElementsByClassName('implicit').length).toBe(1);
+ expect(getByText(`${tagBubbleData.value}`)).toBeInTheDocument();
+ expect(container.getElementsByClassName('implicit').length).toBe(0);
+ expect(container.getElementsByClassName('pgn__chip__icon-after').length).toBe(1);
});
- it('should render value of the explicit tag with no sub tags', () => {
+ it('should call removeTagHandler when "x" clicked on explicit tag', async () => {
const tagBubbleData = {
implicit: false,
...data,
};
- const { container, getByText } = render(
+ const { container } = render(
,
);
- expect(getByText(`${tagBubbleData.value}`)).toBeInTheDocument();
- expect(container.getElementsByClassName('implicit').length).toBe(0);
- expect(container.getElementsByClassName('btn-icon-after').length).toBe(1);
+
+ const xButton = container.getElementsByClassName('pgn__chip__icon-after')[0];
+ await act(async () => {
+ fireEvent.click(xButton);
+ });
+ expect(data.removeTagHandler).toHaveBeenCalled();
});
});
diff --git a/src/content-tags-drawer/TagOutlineIcon.jsx b/src/content-tags-drawer/TagOutlineIcon.jsx
index f817b1f077..7f9d439254 100644
--- a/src/content-tags-drawer/TagOutlineIcon.jsx
+++ b/src/content-tags-drawer/TagOutlineIcon.jsx
@@ -10,7 +10,6 @@ const TagOutlineIcon = (props) => (
aria-hidden="true"
{...props}
>
-
diff --git a/src/content-tags-drawer/__mocks__/index.js b/src/content-tags-drawer/__mocks__/index.js
index b09fc5d3ab..5ec3027386 100644
--- a/src/content-tags-drawer/__mocks__/index.js
+++ b/src/content-tags-drawer/__mocks__/index.js
@@ -1,3 +1,4 @@
export { default as taxonomyTagsMock } from './taxonomyTagsMock';
export { default as contentTaxonomyTagsMock } from './contentTaxonomyTagsMock';
export { default as contentDataMock } from './contentDataMock';
+export { default as updateContentTaxonomyTagsMock } from './updateContentTaxonomyTagsMock';
diff --git a/src/content-tags-drawer/__mocks__/updateContentTaxonomyTagsMock.js b/src/content-tags-drawer/__mocks__/updateContentTaxonomyTagsMock.js
new file mode 100644
index 0000000000..cc319f1197
--- /dev/null
+++ b/src/content-tags-drawer/__mocks__/updateContentTaxonomyTagsMock.js
@@ -0,0 +1,25 @@
+module.exports = {
+ 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b': {
+ taxonomies: [
+ {
+ name: 'FlatTaxonomy',
+ taxonomyId: 3,
+ editable: true,
+ tags: [
+ {
+ value: 'flat taxonomy tag 100',
+ lineage: [
+ 'flat taxonomy tag 100',
+ ],
+ },
+ {
+ value: 'flat taxonomy tag 3856',
+ lineage: [
+ 'flat taxonomy tag 3856',
+ ],
+ },
+ ],
+ },
+ ],
+ },
+};
diff --git a/src/content-tags-drawer/data/api.js b/src/content-tags-drawer/data/api.js
index e63b3d0842..562bfaa1ec 100644
--- a/src/content-tags-drawer/data/api.js
+++ b/src/content-tags-drawer/data/api.js
@@ -9,7 +9,7 @@ export const getContentDataApiUrl = (contentId) => new URL(`/xblock/outline/${co
/**
* Get all tags that belong to taxonomy.
- * @param {string} taxonomyId The id of the taxonomy to fetch tags for
+ * @param {number} 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}
@@ -40,3 +40,17 @@ export async function getContentData(contentId) {
const { data } = await getAuthenticatedHttpClient().get(getContentDataApiUrl(contentId));
return camelCaseObject(data);
}
+
+/**
+ * Update content object's applied tags
+ * @param {string} contentId The id of the content object (unit/component)
+ * @param {number} taxonomyId The id of the taxonomy the tags belong to
+ * @param {string[]} tags The list of tags (values) to set on content object
+ * @returns {Promise}
+ */
+export async function updateContentTaxonomyTags(contentId, taxonomyId, tags) {
+ let url = getContentTaxonomyTagsApiUrl(contentId);
+ url = `${url}?taxonomy=${taxonomyId}`;
+ const { data } = await getAuthenticatedHttpClient().put(url, { tags });
+ return camelCaseObject(data[contentId]);
+}
diff --git a/src/content-tags-drawer/data/api.test.js b/src/content-tags-drawer/data/api.test.js
index ffe19ab960..0d474ee0c2 100644
--- a/src/content-tags-drawer/data/api.test.js
+++ b/src/content-tags-drawer/data/api.test.js
@@ -2,7 +2,12 @@ import MockAdapter from 'axios-mock-adapter';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
-import { taxonomyTagsMock, contentTaxonomyTagsMock, contentDataMock } from '../__mocks__';
+import {
+ taxonomyTagsMock,
+ contentTaxonomyTagsMock,
+ contentDataMock,
+ updateContentTaxonomyTagsMock,
+} from '../__mocks__';
import {
getTaxonomyTagsApiUrl,
@@ -11,6 +16,7 @@ import {
getTaxonomyTagsData,
getContentTaxonomyTagsData,
getContentData,
+ updateContentTaxonomyTags,
} from './api';
let axiosMock;
@@ -33,7 +39,7 @@ describe('content tags drawer api calls', () => {
});
it('should get taxonomy tags data', async () => {
- const taxonomyId = '123';
+ const taxonomyId = 123;
axiosMock.onGet().reply(200, taxonomyTagsMock);
const result = await getTaxonomyTagsData(taxonomyId);
@@ -42,7 +48,7 @@ describe('content tags drawer api calls', () => {
});
it('should get taxonomy tags data with fullPathProvided', async () => {
- const taxonomyId = '123';
+ const taxonomyId = 123;
const fullPathProvided = 'http://example.com/';
axiosMock.onGet().reply(200, taxonomyTagsMock);
const result = await getTaxonomyTagsData(taxonomyId, fullPathProvided);
@@ -68,4 +74,15 @@ describe('content tags drawer api calls', () => {
expect(axiosMock.history.get[0].url).toEqual(getContentDataApiUrl(contentId));
expect(result).toEqual(contentDataMock);
});
+
+ it('should update content taxonomy tags', async () => {
+ const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
+ const taxonomyId = 3;
+ const tags = ['flat taxonomy tag 100', 'flat taxonomy tag 3856'];
+ axiosMock.onPut(`${getContentTaxonomyTagsApiUrl(contentId)}?taxonomy=${taxonomyId}`).reply(200, updateContentTaxonomyTagsMock);
+ const result = await updateContentTaxonomyTags(contentId, taxonomyId, tags);
+
+ expect(axiosMock.history.put[0].url).toEqual(`${getContentTaxonomyTagsApiUrl(contentId)}?taxonomy=${taxonomyId}`);
+ expect(result).toEqual(updateContentTaxonomyTagsMock[contentId]);
+ });
});
diff --git a/src/content-tags-drawer/data/apiHooks.jsx b/src/content-tags-drawer/data/apiHooks.jsx
index 099c88129d..df5f49bebf 100644
--- a/src/content-tags-drawer/data/apiHooks.jsx
+++ b/src/content-tags-drawer/data/apiHooks.jsx
@@ -1,111 +1,71 @@
// @ts-check
-import { useQuery } from '@tanstack/react-query';
-import { getTaxonomyTagsData, getContentTaxonomyTagsData, getContentData } from './api';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import {
+ getTaxonomyTagsData,
+ getContentTaxonomyTagsData,
+ getContentData,
+ updateContentTaxonomyTags,
+} from './api';
/**
* Builds the query to get the taxonomy tags
- * @param {string} taxonomyId The id of the taxonomy to fetch tags for
+ * @param {number} 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 {import("./types.mjs").UseQueryResult}
+ * @returns {import("@tanstack/react-query").UseQueryResult}
*/
-const useTaxonomyTagsData = (taxonomyId, fullPathProvided) => (
+export const useTaxonomyTagsData = (taxonomyId, fullPathProvided) => (
useQuery({
queryKey: [`taxonomyTags${ fullPathProvided || taxonomyId }`],
queryFn: () => getTaxonomyTagsData(taxonomyId, fullPathProvided),
})
);
-/**
- * Gets the taxonomy tags data
- * @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 {import("./types.mjs").TaxonomyTagsData | undefined}
- */
-export const useTaxonomyTagsDataResponse = (taxonomyId, fullPathProvided) => {
- const response = useTaxonomyTagsData(taxonomyId, fullPathProvided);
- if (response.status === 'success') {
- return response.data;
- }
- return undefined;
-};
-
-/**
- * Returns the status of the taxonomy tags query
- * @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 {boolean}
- */
-export const useIsTaxonomyTagsDataLoaded = (taxonomyId, fullPathProvided) => (
- useTaxonomyTagsData(taxonomyId, fullPathProvided).status === 'success'
-);
-
/**
* Builds the query to get the taxonomy tags applied to the content object
* @param {string} contentId The id of the content object to fetch the applied tags for
- * @returns {import("./types.mjs").UseQueryResult}
+ * @returns {import("@tanstack/react-query").UseQueryResult}
*/
-const useContentTaxonomyTagsData = (contentId) => (
+export const useContentTaxonomyTagsData = (contentId) => (
useQuery({
- queryKey: ['contentTaxonomyTags'],
+ queryKey: ['contentTaxonomyTags', contentId],
queryFn: () => getContentTaxonomyTagsData(contentId),
})
);
-/**
- * Gets the taxonomy tags applied to the content object
- * @param {string} contentId The id of the content object to fetch the applied tags for
- * @returns {import("./types.mjs").ContentTaxonomyTagsData | undefined}
- */
-export const useContentTaxonomyTagsDataResponse = (contentId) => {
- const response = useContentTaxonomyTagsData(contentId);
- if (response.status === 'success') {
- return response.data;
- }
- return undefined;
-};
-
-/**
- * Gets the status of the content taxonomy tags query
- * @param {string} contentId The id of the content object to fetch the applied tags for
- * @returns {boolean}
- */
-export const useIsContentTaxonomyTagsDataLoaded = (contentId) => (
- useContentTaxonomyTagsData(contentId).status === 'success'
-);
-
/**
* Builds the query to get meta data about the content object
* @param {string} contentId The id of the content object (unit/component)
- * @returns {import("./types.mjs").UseQueryResult}
+ * @returns {import("@tanstack/react-query").UseQueryResult}
*/
-const useContentData = (contentId) => (
+export const useContentData = (contentId) => (
useQuery({
- queryKey: ['contentData'],
+ queryKey: ['contentData', contentId],
queryFn: () => getContentData(contentId),
})
);
/**
- * Gets the information about the content object
- * @param {string} contentId The id of the content object (unit/component)
- * @returns {import("./types.mjs").ContentData | undefined}
+ * Builds the mutation to update the tags applied to the content object
+ * @param {string} contentId The id of the content object to update tags for
+ * @param {number} taxonomyId The id of the taxonomy the tags belong to
*/
-export const useContentDataResponse = (contentId) => {
- const response = useContentData(contentId);
- if (response.status === 'success') {
- return response.data;
- }
- return undefined;
-};
+export const useContentTaxonomyTagsUpdater = (contentId, taxonomyId) => {
+ const queryClient = useQueryClient();
-/**
- * Gets the status of the content data query
- * @param {string} contentId The id of the content object (unit/component)
- * @returns {boolean}
- */
-export const useIsContentDataLoaded = (contentId) => (
- useContentData(contentId).status === 'success'
-);
+ return useMutation({
+ /**
+ * @type {import("@tanstack/react-query").MutateFunction<
+ * any,
+ * any,
+ * {
+ * tags: string[]
+ * }
+ * >}
+ */
+ mutationFn: ({ tags }) => updateContentTaxonomyTags(contentId, taxonomyId, tags),
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTags', contentId] });
+ },
+ });
+};
diff --git a/src/content-tags-drawer/data/apiHooks.test.jsx b/src/content-tags-drawer/data/apiHooks.test.jsx
index f969782adc..3abf33a223 100644
--- a/src/content-tags-drawer/data/apiHooks.test.jsx
+++ b/src/content-tags-drawer/data/apiHooks.test.jsx
@@ -1,121 +1,96 @@
-import { useQuery } from '@tanstack/react-query';
+import { useQuery, useMutation } from '@tanstack/react-query';
+import { act } from '@testing-library/react';
import {
- useTaxonomyTagsDataResponse,
- useIsTaxonomyTagsDataLoaded,
- useContentTaxonomyTagsDataResponse,
- useIsContentTaxonomyTagsDataLoaded,
- useContentDataResponse,
- useIsContentDataLoaded,
+ useTaxonomyTagsData,
+ useContentTaxonomyTagsData,
+ useContentData,
+ useContentTaxonomyTagsUpdater,
} from './apiHooks';
+import { updateContentTaxonomyTags } from './api';
+
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn(),
+ useMutation: jest.fn(),
+ useQueryClient: jest.fn(),
}));
-describe('useTaxonomyTagsDataResponse', () => {
- it('should return data when status is success', () => {
- useQuery.mockReturnValueOnce({ status: 'success', data: { data: 'data' } });
- const taxonomyId = '123';
- const result = useTaxonomyTagsDataResponse(taxonomyId);
-
- expect(result).toEqual({ data: 'data' });
- });
-
- it('should return undefined when status is not success', () => {
- useQuery.mockReturnValueOnce({ status: 'error' });
- const taxonomyId = '123';
- const result = useTaxonomyTagsDataResponse(taxonomyId);
-
- expect(result).toBeUndefined();
- });
-});
+jest.mock('./api', () => ({
+ updateContentTaxonomyTags: jest.fn(),
+}));
-describe('useIsTaxonomyTagsDataLoaded', () => {
- it('should return true when status is success', () => {
- useQuery.mockReturnValueOnce({ status: 'success' });
- const taxonomyId = '123';
- const result = useIsTaxonomyTagsDataLoaded(taxonomyId);
+describe('useTaxonomyTagsData', () => {
+ it('should return success response', () => {
+ useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
+ const taxonomyId = 123;
+ const result = useTaxonomyTagsData(taxonomyId);
- expect(result).toBe(true);
+ expect(result).toEqual({ isSuccess: true, data: 'data' });
});
- it('should return false when status is not success', () => {
- useQuery.mockReturnValueOnce({ status: 'error' });
- const taxonomyId = '123';
- const result = useIsTaxonomyTagsDataLoaded(taxonomyId);
+ it('should return failure response', () => {
+ useQuery.mockReturnValueOnce({ isSuccess: false });
+ const taxonomyId = 123;
+ const result = useTaxonomyTagsData(taxonomyId);
- expect(result).toBe(false);
+ expect(result).toEqual({ isSuccess: false });
});
});
-describe('useContentTaxonomyTagsDataResponse', () => {
- it('should return data when status is success', () => {
- useQuery.mockReturnValueOnce({ status: 'success', data: { data: 'data' } });
+describe('useContentTaxonomyTagsData', () => {
+ it('should return success response', () => {
+ useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
const contentId = '123';
- const result = useContentTaxonomyTagsDataResponse(contentId);
+ const result = useContentTaxonomyTagsData(contentId);
- expect(result).toEqual({ data: 'data' });
+ expect(result).toEqual({ isSuccess: true, data: 'data' });
});
- it('should return undefined when status is not success', () => {
- useQuery.mockReturnValueOnce({ status: 'error' });
+ it('should return failure response', () => {
+ useQuery.mockReturnValueOnce({ isSuccess: false });
const contentId = '123';
- const result = useContentTaxonomyTagsDataResponse(contentId);
+ const result = useContentTaxonomyTagsData(contentId);
- expect(result).toBeUndefined();
+ expect(result).toEqual({ isSuccess: false });
});
});
-describe('useIsContentTaxonomyTagsDataLoaded', () => {
- it('should return true when status is success', () => {
- useQuery.mockReturnValueOnce({ status: 'success' });
+describe('useContentData', () => {
+ it('should return success response', () => {
+ useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
const contentId = '123';
- const result = useIsContentTaxonomyTagsDataLoaded(contentId);
+ const result = useContentData(contentId);
- expect(result).toBe(true);
+ expect(result).toEqual({ isSuccess: true, data: 'data' });
});
- it('should return false when status is not success', () => {
- useQuery.mockReturnValueOnce({ status: 'error' });
+ it('should return failure response', () => {
+ useQuery.mockReturnValueOnce({ isSuccess: false });
const contentId = '123';
- const result = useIsContentTaxonomyTagsDataLoaded(contentId);
+ const result = useContentData(contentId);
- expect(result).toBe(false);
+ expect(result).toEqual({ isSuccess: false });
});
});
-describe('useContentDataResponse', () => {
- it('should return data when status is success', () => {
- useQuery.mockReturnValueOnce({ status: 'success', data: { data: 'data' } });
- const contentId = '123';
- const result = useContentDataResponse(contentId);
+describe('useContentTaxonomyTagsUpdater', () => {
+ it('should call the update content taxonomy tags function', async () => {
+ useMutation.mockReturnValueOnce({ mutate: jest.fn() });
- expect(result).toEqual({ data: 'data' });
- });
+ const contentId = 'testerContent';
+ const taxonomyId = 123;
+ const mutation = useContentTaxonomyTagsUpdater(contentId, taxonomyId);
+ mutation.mutate({ tags: ['tag1', 'tag2'] });
- it('should return undefined when status is not success', () => {
- useQuery.mockReturnValueOnce({ status: 'error' });
- const contentId = '123';
- const result = useContentDataResponse(contentId);
+ expect(useMutation).toBeCalled();
- expect(result).toBeUndefined();
- });
-});
-
-describe('useIsContentDataLoaded', () => {
- it('should return true when status is success', () => {
- useQuery.mockReturnValueOnce({ status: 'success' });
- const contentId = '123';
- const result = useIsContentDataLoaded(contentId);
-
- expect(result).toBe(true);
- });
-
- it('should return false when status is not success', () => {
- useQuery.mockReturnValueOnce({ status: 'error' });
- const contentId = '123';
- const result = useIsContentDataLoaded(contentId);
+ const [config] = useMutation.mock.calls[0];
+ const { mutationFn } = config;
- expect(result).toBe(false);
+ await act(async () => {
+ const tags = ['tag1', 'tag2'];
+ await mutationFn({ tags });
+ expect(updateContentTaxonomyTags).toBeCalledWith(contentId, taxonomyId, tags);
+ });
});
});
diff --git a/src/content-tags-drawer/messages.js b/src/content-tags-drawer/messages.js
index 6203bb2e83..2fe9deb994 100644
--- a/src/content-tags-drawer/messages.js
+++ b/src/content-tags-drawer/messages.js
@@ -17,6 +17,10 @@ const messages = defineMessages({
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.spinner.loading',
defaultMessage: 'Loading tags',
},
+ loadMoreTagsButtonText: {
+ id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.load-more-tags.button',
+ defaultMessage: 'Load more',
+ },
taxonomyTagsAriaLabel: {
id: 'course-authoring.content-tags-drawer.content-tags-collapsible.selectable-box.selection.aria.label',
defaultMessage: 'taxonomy tags selection',