diff --git a/src/content-tags-drawer/ContentTagsCollapsible.jsx b/src/content-tags-drawer/ContentTagsCollapsible.jsx index 16fd0222f2..09a49569bf 100644 --- a/src/content-tags-drawer/ContentTagsCollapsible.jsx +++ b/src/content-tags-drawer/ContentTagsCollapsible.jsx @@ -8,6 +8,7 @@ import { } from '@openedx/paragon'; import PropTypes from 'prop-types'; import classNames from 'classnames'; +import { cloneDeep } from 'lodash'; import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; import { debounce } from 'lodash'; import messages from './messages'; @@ -20,7 +21,15 @@ import ContentTagsTree from './ContentTagsTree'; import useContentTagsCollapsibleHelper from './ContentTagsCollapsibleHelper'; const CustomMenu = (props) => { - const { intl, handleSelectableBoxChange, checkedTags, taxonomyId, tagsTree, searchTerm } = props.selectProps; + const { + intl, + handleSelectableBoxChange, + checkedTags, + taxonomyId, + appliedContentTagsTree, + stagedContentTagsTree, + searchTerm, + } = props.selectProps; return (
@@ -38,7 +47,8 @@ const CustomMenu = (props) => { key={`selector-${taxonomyId}`} taxonomyId={taxonomyId} level={0} - tagsTree={tagsTree} + appliedContentTagsTree={appliedContentTagsTree} + stagedContentTagsTree={stagedContentTagsTree} searchTerm={searchTerm} /> @@ -47,6 +57,8 @@ const CustomMenu = (props) => { ); }; +// TODO: Add props validation for CustomMenu + /** @typedef {import("../taxonomy/data/types.mjs").TaxonomyData} TaxonomyData */ /** @typedef {import("./data/types.mjs").Tag} ContentTagData */ @@ -123,18 +135,25 @@ const CustomMenu = (props) => { * * @param {Object} props - The component props. * @param {string} props.contentId - Id of the content object + * @param {Array} props.stagedContentTags - Array of staged tags represented objects with value/label + * @param {Function} props.addStagedContentTag - Callback function to add a staged tag for a taxonomy + * @param {Function} props.removeStagedContentTag - Callback function to remove a staged tag from a taxonomy + * @param {Function} props.setStagedTags - Callback function to set staged tags for a taxonomy to provided tags list * @param {TaxonomyData & {contentTags: ContentTagData[]}} props.taxonomyAndTagsData - Taxonomy metadata & applied tags */ const ContentTagsCollapsible = ({ - contentId, taxonomyAndTagsData, stagedContentTags, addStagedContentTag, removeStagedContentTag, + contentId, taxonomyAndTagsData, stagedContentTags, addStagedContentTag, removeStagedContentTag, setStagedTags, }) => { const intl = useIntl(); const { id: taxonomyId, name, canTagObject } = taxonomyAndTagsData; const { - tagChangeHandler, tagsTree, contentTagsCount, checkedTags, + tagChangeHandler, appliedContentTagsTree, stagedContentTagsTree, contentTagsCount, checkedTags, } = useContentTagsCollapsibleHelper( - contentId, taxonomyAndTagsData, addStagedContentTag, removeStagedContentTag, + contentId, + taxonomyAndTagsData, + addStagedContentTag, + removeStagedContentTag, ); const [searchTerm, setSearchTerm] = React.useState(''); @@ -157,11 +176,31 @@ const ContentTagsCollapsible = ({ } }, []); + // onChange handler for react-select component, currently only called when + // staged tags in the react-select input are removed or fully cleared. + // The remaining staged tags are passed in as the parameter, so we set the state + // to the passed in tags + const handleStagedTagsMenuChange = React.useCallback((stagedTags) => { + const prevStagedContentTags = cloneDeep(stagedContentTags); + setStagedTags(taxonomyId, stagedTags); + + // Get tags that were unstaged to remove them from checkbox selector + const unstagedTags = prevStagedContentTags.filter( + t1 => !stagedTags.some(t2 => t1.value === t2.value && t1.label === t2.label), + ); + + // Call the `tagChangeHandler` with the unstaged tags to unselect them from the selectbox + // and update the staged content tags tree. Since the `handleStagedTagsMenuChange` function is + // only called when a change occurs in the react-select menu component we know that tags can only be + // removed from there, hence the tagChangeHandler is always called with `checked=false`. + unstagedTags.forEach(unstagedTag => tagChangeHandler(unstagedTag.value, false)); + }, [taxonomyId, setStagedTags, stagedContentTags, tagChangeHandler]); + return (
- +
@@ -174,7 +213,7 @@ const ContentTagsCollapsible = ({ className="d-flex flex-column flex-fill" classNamePrefix="react-select" onInputChange={handleSearchChange} - onChange={(e) => console.log('onChange', e)} + onChange={handleStagedTagsMenuChange} components={{ Menu: CustomMenu }} closeMenuOnSelect={false} blurInputOnSelect={false} @@ -182,14 +221,10 @@ const ContentTagsCollapsible = ({ handleSelectableBoxChange={handleSelectableBoxChange} checkedTags={checkedTags} taxonomyId={taxonomyId} - tagsTree={tagsTree} + appliedContentTagsTree={appliedContentTagsTree} + stagedContentTagsTree={stagedContentTagsTree} searchTerm={searchTerm} value={stagedContentTags} - // value={[ - // { value: 'Administration,Administrative%20Support,Administrative%20Functions', label: 'Administrative Functions' }, - // { value: 'Administration,Administrative%20Support,Memos', label: 'Memos' }, - // { value: 'Another%20One', label: 'Another One' }, - // ]} // TODO: this needs to be staged (not yet commited tags) in the above format /> )}
@@ -226,6 +261,7 @@ ContentTagsCollapsible.propTypes = { })).isRequired, addStagedContentTag: PropTypes.func.isRequired, removeStagedContentTag: PropTypes.func.isRequired, + setStagedTags: PropTypes.func.isRequired, }; export default ContentTagsCollapsible; diff --git a/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx b/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx index a7f3bd8b48..abfd942338 100644 --- a/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx +++ b/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx @@ -5,6 +5,25 @@ import { cloneDeep } from 'lodash'; import { useContentTaxonomyTagsUpdater } from './data/apiHooks'; +/** + * Util function that sorts the keys of a tree in alphabetical order. + * + * @param {object} tree - tree that needs it's keys sorted + * @returns {object} merged tree containing both tree1 and tree2 + */ +const sortKeysAlphabetically = (tree) => { + const sortedObj = {}; + Object.keys(tree) + .sort() + .forEach((key) => { + sortedObj[key] = tree[key]; + if (tree[key] && typeof tree[key] === 'object') { + sortedObj[key].children = sortKeysAlphabetically(tree[key].children); + } + }); + return sortedObj; +}; + /** * Util function that consolidates two tag trees into one, sorting the keys in * alphabetical order. @@ -16,19 +35,6 @@ import { useContentTaxonomyTagsUpdater } from './data/apiHooks'; 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]; @@ -91,7 +97,7 @@ const useContentTagsCollapsibleHelper = ( // Keeps track of the tree structure for tags that are add by selecting/unselecting // tags in the dropdowns. - const [addedContentTags, setAddedContentTags] = React.useState({}); + const [stagedContentTagsTree, setStagedContentTagsTree] = React.useState({}); // To handle checking/unchecking tags in the SelectableBox const [checkedTags, { add, remove, clear }] = useCheckboxSetValues(); @@ -115,12 +121,12 @@ const useContentTagsCollapsibleHelper = ( // ================================================================== // This converts the contentTags prop to the tree structure mentioned above - const appliedContentTags = React.useMemo(() => { + const appliedContentTagsTree = 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({}); + setStagedContentTagsTree({}); clear(); // When an error occurs while updating, the contentTags query is invalidated, @@ -158,17 +164,9 @@ const useContentTagsCollapsibleHelper = ( 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, taxonomyId) => { + const addTags = (tree, tagLineage, selectedTag) => { const value = []; let traversal = tree; tagLineage.forEach(tag => { @@ -184,7 +182,7 @@ const useContentTagsCollapsibleHelper = ( } else { traversal[tag].explicit = isExplicit; if (!isExplicit) { - removeStagedContentTag(taxonomyId, tag); + removeStagedContentTag(id, tag); } } @@ -202,12 +200,15 @@ const useContentTagsCollapsibleHelper = ( const tagLineage = tagSelectableBoxValue.split(',').map(t => decodeURIComponent(t)); const selectedTag = tagLineage.slice(-1)[0]; - const addedTree = { ...addedContentTags }; if (checked) { + const stagedTree = cloneDeep(stagedContentTagsTree); // We "add" the tag to the SelectableBox.Set inside the addTags method - addTags(addedTree, tagLineage, selectedTag, id); + addTags(stagedTree, tagLineage, selectedTag); + + // Update the staged content tags tree + setStagedContentTagsTree(stagedTree); - // Add content tag to taxonomy's staged tags + // Add content tag to taxonomy's staged tags select menu addStagedContentTag( id, { @@ -219,24 +220,30 @@ const useContentTagsCollapsibleHelper = ( // 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); + // Remove tag along with it's from ancestors if it's the only child tag + // from the staged tags tree and update the staged content tags tree + setStagedContentTagsTree(prevStagedContentTagsTree => { + const updatedStagedContentTagsTree = cloneDeep(prevStagedContentTagsTree); + removeTags(updatedStagedContentTagsTree, tagLineage); + return updatedStagedContentTagsTree; + }); - // Remove content tag from taxonomy's staged tags + // Remove content tag from taxonomy's staged tags select menu removeStagedContentTag(id, selectedTag); } - setAddedContentTags(addedTree); // setUpdatingTags(true); }, [ - addedContentTags, setAddedContentTags, addTags, removeTags, remove, + stagedContentTagsTree, setStagedContentTagsTree, addTags, removeTags, removeTags, id, addStagedContentTag, removeStagedContentTag, ]); return { - tagChangeHandler, tagsTree, contentTagsCount, checkedTags, + tagChangeHandler, + appliedContentTagsTree: sortKeysAlphabetically(appliedContentTagsTree), + stagedContentTagsTree: sortKeysAlphabetically(stagedContentTagsTree), + contentTagsCount, + checkedTags, }; }; diff --git a/src/content-tags-drawer/ContentTagsDrawer.jsx b/src/content-tags-drawer/ContentTagsDrawer.jsx index 9d245baf0e..fc7639475a 100644 --- a/src/content-tags-drawer/ContentTagsDrawer.jsx +++ b/src/content-tags-drawer/ContentTagsDrawer.jsx @@ -59,6 +59,15 @@ const ContentTagsDrawer = () => { }); }, [setStagedContentTags]); + // Sets the staged content tags for taxonomy to the provided list of tags + const setStagedTags = useCallback((taxonomyId, tagsList) => { + setStagedContentTags(prevStagedContentTags => { + const updatedStagedContentTags = cloneDeep(prevStagedContentTags); + updatedStagedContentTags[taxonomyId] = tagsList; + return updatedStagedContentTags; + }); + }, [setStagedContentTags]); + const useTaxonomyListData = () => { const taxonomyListData = useTaxonomyListDataResponse(org); const isTaxonomyListLoaded = useIsTaxonomyListDataLoaded(org); @@ -144,6 +153,7 @@ const ContentTagsDrawer = () => { stagedContentTags={stagedContentTags[data.id] || []} addStagedContentTag={addStagedContentTag} removeStagedContentTag={removeStagedContentTag} + setStagedTags={setStagedTags} />
diff --git a/src/content-tags-drawer/ContentTagsDropDownSelector.jsx b/src/content-tags-drawer/ContentTagsDropDownSelector.jsx index d82a74e00a..fc32570283 100644 --- a/src/content-tags-drawer/ContentTagsDropDownSelector.jsx +++ b/src/content-tags-drawer/ContentTagsDropDownSelector.jsx @@ -42,7 +42,7 @@ HighlightedText.defaultProps = { }; const ContentTagsDropDownSelector = ({ - taxonomyId, level, lineage, tagsTree, searchTerm, + taxonomyId, level, lineage, appliedContentTagsTree, stagedContentTagsTree, searchTerm, }) => { const intl = useIntl(); @@ -89,13 +89,30 @@ const ContentTagsDropDownSelector = ({ }; const isImplicit = (tag) => { - // Traverse the tags tree using the lineage - let traversal = tagsTree; + // Traverse the applied tags tree using the lineage + let appliedTraversal = appliedContentTagsTree; lineage.forEach(t => { - traversal = traversal[t]?.children || {}; + appliedTraversal = appliedTraversal[t]?.children || {}; }); + const isAppliedImplicit = (appliedTraversal[tag.value] && !appliedTraversal[tag.value].explicit); - return (traversal[tag.value] && !traversal[tag.value].explicit) || false; + // Traverse the staged tags tree using the lineage + let stagedTraversal = stagedContentTagsTree; + lineage.forEach(t => { + stagedTraversal = stagedTraversal[t]?.children || {}; + }); + const isStagedImplicit = (stagedTraversal[tag.value] && !stagedTraversal[tag.value].explicit); + + return isAppliedImplicit || isStagedImplicit || false; + }; + + const isApplied = (tag) => { + // Traverse the applied tags tree using the lineage + let appliedTraversal = appliedContentTagsTree; + lineage.forEach(t => { + appliedTraversal = appliedTraversal[t]?.children || {}; + }); + return !!appliedTraversal[tag.value]; }; const loadMoreTags = useCallback(() => { @@ -132,7 +149,7 @@ const ContentTagsDropDownSelector = ({ data-selectable-box="taxonomy-tags" value={[...lineage, tagData.value].map(t => encodeURIComponent(t)).join(',')} isIndeterminate={isImplicit(tagData)} - disabled={isImplicit(tagData)} + disabled={isApplied(tagData) || isImplicit(tagData)} > @@ -156,7 +173,8 @@ const ContentTagsDropDownSelector = ({ taxonomyId={taxonomyId} level={level + 1} lineage={[...lineage, tagData.value]} - tagsTree={tagsTree} + appliedContentTagsTree={appliedContentTagsTree} + stagedContentTagsTree={stagedContentTagsTree} searchTerm={searchTerm} /> )} @@ -197,7 +215,13 @@ ContentTagsDropDownSelector.propTypes = { taxonomyId: PropTypes.number.isRequired, level: PropTypes.number.isRequired, lineage: PropTypes.arrayOf(PropTypes.string), - tagsTree: PropTypes.objectOf( + appliedContentTagsTree: PropTypes.objectOf( + PropTypes.shape({ + explicit: PropTypes.bool.isRequired, + children: PropTypes.shape({}).isRequired, + }).isRequired, + ).isRequired, + stagedContentTagsTree: PropTypes.objectOf( PropTypes.shape({ explicit: PropTypes.bool.isRequired, children: PropTypes.shape({}).isRequired,