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 (
- +
- + + {editable && ( + + )}
@@ -76,11 +155,14 @@ const ContentTagsCollapsible = ({ taxonomyAndTagsData }) => { columns={1} ariaLabel={intl.formatMessage(messages.taxonomyTagsAriaLabel)} className="taxonomy-tags-selectable-box-set" + onChange={handleSelectableBoxChange} + value={checkedTags} >
@@ -92,11 +174,10 @@ const ContentTagsCollapsible = ({ taxonomyAndTagsData }) => { variant="light" pill className={classNames('align-self-start', 'mt-3', { - // eslint-disable-next-line quote-props - 'invisible': contentTags.length === 0, + invisible: contentTagsCount === 0, })} > - {contentTags.length} + {contentTagsCount} @@ -104,6 +185,7 @@ const ContentTagsCollapsible = ({ taxonomyAndTagsData }) => { }; ContentTagsCollapsible.propTypes = { + contentId: PropTypes.string.isRequired, taxonomyAndTagsData: PropTypes.shape({ id: PropTypes.number, name: PropTypes.string, @@ -112,6 +194,7 @@ ContentTagsCollapsible.propTypes = { lineage: PropTypes.arrayOf(PropTypes.string), })), }).isRequired, + editable: PropTypes.bool.isRequired, }; export default ContentTagsCollapsible; diff --git a/src/content-tags-drawer/ContentTagsCollapsible.scss b/src/content-tags-drawer/ContentTagsCollapsible.scss index a207fd9474..e55d597af3 100644 --- a/src/content-tags-drawer/ContentTagsCollapsible.scss +++ b/src/content-tags-drawer/ContentTagsCollapsible.scss @@ -22,3 +22,7 @@ overflow-y: scroll; max-height: 20rem; } + +.pgn__modal-popup__arrow { + visibility: hidden; +} diff --git a/src/content-tags-drawer/ContentTagsCollapsible.test.jsx b/src/content-tags-drawer/ContentTagsCollapsible.test.jsx index eca8c94bfc..096eb27457 100644 --- a/src/content-tags-drawer/ContentTagsCollapsible.test.jsx +++ b/src/content-tags-drawer/ContentTagsCollapsible.test.jsx @@ -1,37 +1,59 @@ import React from 'react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { act, render } from '@testing-library/react'; +import { + act, + render, + fireEvent, + waitFor, +} from '@testing-library/react'; import PropTypes from 'prop-types'; import ContentTagsCollapsible from './ContentTagsCollapsible'; +import messages from './messages'; +import { useTaxonomyTagsData } from './data/apiHooks'; jest.mock('./data/apiHooks', () => ({ - useTaxonomyTagsDataResponse: jest.fn(), - useIsTaxonomyTagsDataLoaded: jest.fn(), + useContentTaxonomyTagsUpdater: jest.fn(() => ({ + isError: false, + mutate: jest.fn(), + })), + useTaxonomyTagsData: jest.fn(() => ({ + isSuccess: false, + data: {}, + })), })); const data = { - id: 123, - name: 'Taxonomy 1', - contentTags: [ - { - value: 'Tag 1', - lineage: ['Tag 1'], - }, - { - value: 'Tag 2', - lineage: ['Tag 2'], - }, - ], + contentId: 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab', + taxonomyAndTagsData: { + id: 123, + name: 'Taxonomy 1', + contentTags: [ + { + value: 'Tag 1', + lineage: ['Tag 1'], + }, + { + value: 'Tag 1.1', + lineage: ['Tag 1', 'Tag 1.1'], + }, + { + value: 'Tag 2', + lineage: ['Tag 2'], + }, + ], + }, + editable: true, }; -const ContentTagsCollapsibleComponent = ({ taxonomyAndTagsData }) => ( +const ContentTagsCollapsibleComponent = ({ contentId, taxonomyAndTagsData, editable }) => ( - + ); ContentTagsCollapsibleComponent.propTypes = { + contentId: PropTypes.string.isRequired, taxonomyAndTagsData: PropTypes.shape({ id: PropTypes.number, name: PropTypes.string, @@ -40,22 +62,155 @@ ContentTagsCollapsibleComponent.propTypes = { lineage: PropTypes.arrayOf(PropTypes.string), })), }).isRequired, + editable: PropTypes.bool.isRequired, }; describe('', () => { it('should render taxonomy tags data along content tags number badge', async () => { await act(async () => { - const { container, getByText } = render(); + const { container, getByText } = render( + , + ); expect(getByText('Taxonomy 1')).toBeInTheDocument(); expect(container.getElementsByClassName('badge').length).toBe(1); - expect(getByText('2')).toBeInTheDocument(); + expect(getByText('3')).toBeInTheDocument(); + }); + }); + + it('should render new tags as they are checked in the dropdown', async () => { + useTaxonomyTagsData.mockReturnValue({ + isSuccess: true, + data: { + results: [{ + value: 'Tag 1', + subTagsUrl: null, + }, { + value: 'Tag 2', + subTagsUrl: null, + }, { + value: 'Tag 3', + subTagsUrl: null, + }], + }, + }); + + await act(async () => { + const { container, getByText, getAllByText } = render( + , + ); + + // Expand the Taxonomy to view applied tags and "Add tags" button + const expandToggle = container.getElementsByClassName('collapsible-trigger')[0]; + await act(async () => { + fireEvent.click(expandToggle); + }); + + // Click on "Add tags" button to open dropdown to select new tags + const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage); + await act(async () => { + fireEvent.click(addTagsButton); + }); + + // Wait for the dropdown selector for tags to open, + // Tag 3 should only appear there + await waitFor(() => { + expect(getByText('Tag 3')).toBeInTheDocument(); + expect(getAllByText('Tag 3').length === 1); + }); + + const tag3 = getByText('Tag 3'); + await act(async () => { + fireEvent.click(tag3); + }); + + // After clicking on Tag 3, it should also appear in amongst + // the tag bubbles in the tree + await waitFor(() => { + expect(getAllByText('Tag 3').length === 2); + }); + }); + }); + + it('should remove tag when they are unchecked in the dropdown', async () => { + useTaxonomyTagsData.mockReturnValue({ + isSuccess: true, + data: { + results: [{ + value: 'Tag 1', + subTagsUrl: null, + }, { + value: 'Tag 2', + subTagsUrl: null, + }, { + value: 'Tag 3', + subTagsUrl: null, + }], + }, + }); + + await act(async () => { + const { container, getByText, getAllByText } = render( + , + ); + + // Expand the Taxonomy to view applied tags and "Add tags" button + const expandToggle = container.getElementsByClassName('collapsible-trigger')[0]; + await act(async () => { + fireEvent.click(expandToggle); + }); + + // Check that Tag 2 appears in tag bubbles + await waitFor(() => { + expect(getByText('Tag 2')).toBeInTheDocument(); + }); + + // Click on "Add tags" button to open dropdown to select new tags + const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage); + await act(async () => { + fireEvent.click(addTagsButton); + }); + + // Wait for the dropdown selector for tags to open, + // Tag 3 should only appear there, (i.e. the dropdown is open, since Tag 3 is not applied) + await waitFor(() => { + expect(getByText('Tag 3')).toBeInTheDocument(); + }); + + // Get the Tag 2 checkbox and click on it + const tag2 = getAllByText('Tag 2')[1]; + await act(async () => { + fireEvent.click(tag2); + }); + + // After clicking on Tag 2, it should be removed from + // the tag bubbles in so only the one in the dropdown appears + expect(getAllByText('Tag 2').length === 1); }); }); it('should render taxonomy tags data without tags number badge', async () => { - data.contentTags = []; + const updatedData = { ...data }; + updatedData.taxonomyAndTagsData.contentTags = []; await act(async () => { - const { container, getByText } = render(); + const { container, getByText } = render( + , + ); expect(getByText('Taxonomy 1')).toBeInTheDocument(); expect(container.getElementsByClassName('invisible').length).toBe(1); }); diff --git a/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx b/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx new file mode 100644 index 0000000000..3692e15492 --- /dev/null +++ b/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx @@ -0,0 +1,206 @@ +import React from 'react'; +import { useCheckboxSetValues } from '@edx/paragon'; +import { cloneDeep } from 'lodash'; + +import { useContentTaxonomyTagsUpdater } from './data/apiHooks'; + +/** + * Util function that consolidates two tag trees into one, sorting the keys in + * alphabetical order. + * + * @param {object} tree1 - first tag tree + * @param {object} tree2 - second tag tree + * @returns {object} merged tree containing both tree1 and tree2 + */ +const mergeTrees = (tree1, tree2) => { + const mergedTree = cloneDeep(tree1); + + const sortKeysAlphabetically = (obj) => { + const sortedObj = {}; + Object.keys(obj) + .sort() + .forEach((key) => { + sortedObj[key] = obj[key]; + if (obj[key] && typeof obj[key] === 'object') { + sortedObj[key].children = sortKeysAlphabetically(obj[key].children); + } + }); + return sortedObj; + }; + + const mergeRecursively = (destination, source) => { + Object.entries(source).forEach(([key, sourceValue]) => { + const destinationValue = destination[key]; + + if (destinationValue && sourceValue && typeof destinationValue === 'object' && typeof sourceValue === 'object') { + mergeRecursively(destinationValue, sourceValue); + } else { + // eslint-disable-next-line no-param-reassign + destination[key] = cloneDeep(sourceValue); + } + }); + }; + + mergeRecursively(mergedTree, tree2); + return sortKeysAlphabetically(mergedTree); +}; + +/** + * Util function that removes the tag along with its ancestors if it was + * the only explicit child tag. + * + * @param {object} tree - tag tree to remove the tag from + * @param {string[]} tagsToRemove - full lineage of tag to remove. + * eg: ['grand parent', 'parent', 'tag'] + */ +const removeTags = (tree, tagsToRemove) => { + if (!tree || !tagsToRemove.length) { + return; + } + const key = tagsToRemove[0]; + if (tree[key]) { + removeTags(tree[key].children, tagsToRemove.slice(1)); + + if (Object.keys(tree[key].children).length === 0 && (tree[key].explicit === false || tagsToRemove.length === 1)) { + // eslint-disable-next-line no-param-reassign + delete tree[key]; + } + } +}; + +/* + * Handles all the underlying logic for the ContentTagsCollapsible component + */ +const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => { + const { + id, contentTags, + } = taxonomyAndTagsData; + // State to determine whether the tags are being updating so we can make a call + // to the update endpoint to the reflect those changes + const [updatingTags, setUpdatingTags] = React.useState(false); + const updateTags = useContentTaxonomyTagsUpdater(contentId, id); + + // Keeps track of the content objects tags count (both implicit and explicit) + const [contentTagsCount, setContentTagsCount] = React.useState(0); + + // Keeps track of the tree structure for tags that are add by selecting/unselecting + // tags in the dropdowns. + const [addedContentTags, setAddedContentTags] = React.useState({}); + + // To handle checking/unchecking tags in the SelectableBox + const [checkedTags, { add, remove, clear }] = useCheckboxSetValues(); + + // Handles making requests to the update endpoint whenever the checked tags change + React.useEffect(() => { + // We have this check because this hook is fired when the component first loads + // and reloads (on refocus). We only want to make a request to the update endpoint when + // the user is updating the tags. + if (updatingTags) { + setUpdatingTags(false); + const tags = checkedTags.map(t => decodeURIComponent(t.split(',').slice(-1))); + updateTags.mutate({ tags }); + } + }, [contentId, id, checkedTags]); + + // This converts the contentTags prop to the tree structure mentioned above + const appliedContentTags = React.useMemo(() => { + let contentTagsCounter = 0; + + // Clear all the tags that have not been commited and the checked boxes when + // fresh contentTags passed in so the latest state from the backend is rendered + setAddedContentTags({}); + clear(); + + // When an error occurs while updating, the contentTags query is invalidated, + // hence they will be recalculated, and the updateTags mutation should be reset. + if (updateTags.isError) { + updateTags.reset(); + } + + const resultTree = {}; + contentTags.forEach(item => { + let currentLevel = resultTree; + + item.lineage.forEach((key, index) => { + if (!currentLevel[key]) { + const isExplicit = index === item.lineage.length - 1; + currentLevel[key] = { + explicit: isExplicit, + children: {}, + }; + + // Populating the SelectableBox with "selected" (explicit) tags + const value = item.lineage.map(l => encodeURIComponent(l)).join(','); + // eslint-disable-next-line no-unused-expressions + isExplicit ? add(value) : remove(value); + contentTagsCounter += 1; + } + + currentLevel = currentLevel[key].children; + }); + }); + + setContentTagsCount(contentTagsCounter); + return resultTree; + }, [contentTags, updateTags.isError]); + + // This is the source of truth that represents the current state of tags in + // this Taxonomy as a tree. Whenever either the `appliedContentTags` (i.e. tags passed in + // the prop from the backed) change, or when the `addedContentTags` (i.e. tags added by + // selecting/unselecting them in the dropdown) change, the tree is recomputed. + const tagsTree = React.useMemo(() => ( + mergeTrees(appliedContentTags, addedContentTags) + ), [appliedContentTags, addedContentTags]); + + // Add tag to the tree, and while traversing remove any selected ancestor tags + // as they should become implicit + const addTags = (tree, tagLineage, selectedTag) => { + const value = []; + let traversal = tree; + tagLineage.forEach(tag => { + const isExplicit = selectedTag === tag; + + if (!traversal[tag]) { + traversal[tag] = { explicit: isExplicit, children: {} }; + } else { + traversal[tag].explicit = isExplicit; + } + + // Clear out the ancestor tags leading to newly selected tag + // as they automatically become implicit + value.push(encodeURIComponent(tag)); + // eslint-disable-next-line no-unused-expressions + isExplicit ? add(value.join(',')) : remove(value.join(',')); + + traversal = traversal[tag].children; + }); + }; + + const tagChangeHandler = React.useCallback((tagSelectableBoxValue, checked) => { + const tagLineage = tagSelectableBoxValue.split(',').map(t => decodeURIComponent(t)); + const selectedTag = tagLineage.slice(-1)[0]; + + const addedTree = { ...addedContentTags }; + if (checked) { + // We "add" the tag to the SelectableBox.Set inside the addTags method + addTags(addedTree, tagLineage, selectedTag); + } else { + // Remove tag from the SelectableBox.Set + remove(tagSelectableBoxValue); + + // We remove them from both incase we are unselecting from an + // existing applied Tag or a newly added one + removeTags(addedTree, tagLineage); + removeTags(appliedContentTags, tagLineage); + } + + setAddedContentTags(addedTree); + setUpdatingTags(true); + }); + + return { + tagChangeHandler, tagsTree, contentTagsCount, checkedTags, + }; +}; + +export default useContentTagsCollapsibleHelper; diff --git a/src/content-tags-drawer/ContentTagsDrawer.jsx b/src/content-tags-drawer/ContentTagsDrawer.jsx index eeed39be1d..071c8d4075 100644 --- a/src/content-tags-drawer/ContentTagsDrawer.jsx +++ b/src/content-tags-drawer/ContentTagsDrawer.jsx @@ -10,10 +10,8 @@ import messages from './messages'; import ContentTagsCollapsible from './ContentTagsCollapsible'; import { extractOrgFromContentId } from './utils'; import { - useContentTaxonomyTagsDataResponse, - useIsContentTaxonomyTagsDataLoaded, - useContentDataResponse, - useIsContentDataLoaded, + useContentTaxonomyTagsData, + useContentData, } from './data/apiHooks'; import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks'; import Loading from '../generic/Loading'; @@ -24,26 +22,17 @@ const ContentTagsDrawer = () => { 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 { data: contentData, isSuccess: isContentDataLoaded } = useContentData(contentId); + const { + data: contentTaxonomyTagsData, + isSuccess: isContentTaxonomyTagsLoaded, + } = useContentTaxonomyTagsData(contentId); const { taxonomyListData, isTaxonomyListLoaded } = useTaxonomyListData(); const closeContentTagsDrawer = () => { @@ -113,7 +102,8 @@ const ContentTagsDrawer = () => { { isTaxonomyListLoaded && isContentTaxonomyTagsLoaded ? taxonomies.map((data) => (
- + {/* TODO: Properly set whether tags should be editable or not based on permissions */} +
)) diff --git a/src/content-tags-drawer/ContentTagsDrawer.test.jsx b/src/content-tags-drawer/ContentTagsDrawer.test.jsx index ad479b1569..c722fb48be 100644 --- a/src/content-tags-drawer/ContentTagsDrawer.test.jsx +++ b/src/content-tags-drawer/ContentTagsDrawer.test.jsx @@ -4,10 +4,8 @@ import { act, render, fireEvent } from '@testing-library/react'; import ContentTagsDrawer from './ContentTagsDrawer'; import { - useContentTaxonomyTagsDataResponse, - useIsContentTaxonomyTagsDataLoaded, - useContentDataResponse, - useIsContentDataLoaded, + useContentTaxonomyTagsData, + useContentData, } from './data/apiHooks'; import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks'; @@ -19,12 +17,17 @@ jest.mock('react-router-dom', () => ({ })); jest.mock('./data/apiHooks', () => ({ - useContentTaxonomyTagsDataResponse: jest.fn(), - useIsContentTaxonomyTagsDataLoaded: jest.fn(), - useContentDataResponse: jest.fn(), - useIsContentDataLoaded: jest.fn(), - useTaxonomyTagsDataResponse: jest.fn(), - useIsTaxonomyTagsDataLoaded: jest.fn(), + useContentTaxonomyTagsData: jest.fn(() => ({ + isSuccess: false, + data: {}, + })), + useContentData: jest.fn(() => ({ + isSuccess: false, + data: {}, + })), + useContentTaxonomyTagsUpdater: jest.fn(() => ({ + isError: false, + })), })); jest.mock('../taxonomy/data/apiHooks', () => ({ @@ -45,7 +48,6 @@ describe('', () => { }); 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]; @@ -55,7 +57,6 @@ describe('', () => { 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]; @@ -64,9 +65,11 @@ describe('', () => { }); it('shows the content display name after the query is complete', async () => { - useIsContentDataLoaded.mockReturnValue(true); - useContentDataResponse.mockReturnValue({ - displayName: 'Unit 1', + useContentData.mockReturnValue({ + isSuccess: true, + data: { + displayName: 'Unit 1', + }, }); await act(async () => { const { getByText } = render(); @@ -76,36 +79,38 @@ describe('', () => { 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'], - }, - ], - }, - ], + useContentTaxonomyTagsData.mockReturnValue({ + isSuccess: true, + data: { + 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: [{ diff --git a/src/content-tags-drawer/ContentTagsDropDownSelector.jsx b/src/content-tags-drawer/ContentTagsDropDownSelector.jsx index e71ac8a80f..bde6d5e968 100644 --- a/src/content-tags-drawer/ContentTagsDropDownSelector.jsx +++ b/src/content-tags-drawer/ContentTagsDropDownSelector.jsx @@ -1,19 +1,20 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { SelectableBox, Icon, Spinner, + Button, } from '@edx/paragon'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { useIntl, FormattedMessage } 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'; +import { useTaxonomyTagsData } from './data/apiHooks'; const ContentTagsDropDownSelector = ({ - taxonomyId, level, subTagsUrl, + taxonomyId, level, subTagsUrl, lineage, tagsTree, }) => { const intl = useIntl(); // This object represents the states of the dropdowns on this level @@ -21,6 +22,19 @@ const ContentTagsDropDownSelector = ({ // the value true (open) false (closed) const [dropdownStates, setDropdownStates] = useState({}); + const [tags, setTags] = useState([]); + const [nextPage, setNextPage] = useState(null); + + // `fetchUrl` is initially `subTagsUrl` to fetch the initial data, + // however if it is null that means it is the root, and the apiHooks + // would automatically handle it. Later this url is set to the next + // page of results (if any) + // + // TODO: In the future we may need to refactor this to keep track + // of the count for how many times the user clicked on "load more" then + // use useQueries to load all the pages based on that. + const [fetchUrl, setFetchUrl] = useState(subTagsUrl); + const isOpen = (i) => dropdownStates[i]; const clickAndEnterHandler = (i) => { @@ -29,12 +43,33 @@ const ContentTagsDropDownSelector = ({ setDropdownStates({ ...dropdownStates, [i]: !dropdownStates[i] }); }; - const taxonomyTagsData = useTaxonomyTagsDataResponse(taxonomyId, subTagsUrl); - const isTaxonomyTagsLoaded = useIsTaxonomyTagsDataLoaded(taxonomyId, subTagsUrl); + const { data: taxonomyTagsData, isSuccess: isTaxonomyTagsLoaded } = useTaxonomyTagsData(taxonomyId, fetchUrl); + + const isImplicit = (tag) => { + // Traverse the tags tree using the lineage + let traversal = tagsTree; + lineage.forEach(t => { + // We need to decode the tag to traverse the tree since the lineage value is encoded + traversal = traversal[decodeURIComponent(t)]?.children || {}; + }); + + return (traversal[tag.value] && !traversal[tag.value].explicit) || false; + }; + + useEffect(() => { + if (isTaxonomyTagsLoaded && taxonomyTagsData) { + setTags([...tags, ...taxonomyTagsData.results]); + setNextPage(taxonomyTagsData.next); + } + }, [isTaxonomyTagsLoaded, taxonomyTagsData]); + + const loadMoreTags = useCallback(() => { + setFetchUrl(nextPage); + }, [nextPage]); return ( - isTaxonomyTagsLoaded && taxonomyTagsData - ? taxonomyTagsData.results.map((taxonomyTag, i) => ( + <> + {tags.map((taxonomyTag, i) => (
{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} +
); }; 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',