From 6bfd13b3e2c7b0522caca06e82898044b68b7165 Mon Sep 17 00:00:00 2001 From: Yusuf Musleh Date: Tue, 20 Feb 2024 18:00:29 +0300 Subject: [PATCH] feat: Split up applied & staged content tags trees Now content tags have seperate tree states for applied ones and staged ones. They are updated seperately and both are used when updating the selectable box UI. This allows for more flexibility with actions that can be performed on the staged content tags with impacting the applied ones. --- .../ContentTagsCollapsible.jsx | 62 +++++++++++--- .../ContentTagsCollapsibleHelper.jsx | 81 ++++++++++--------- src/content-tags-drawer/ContentTagsDrawer.jsx | 10 +++ .../ContentTagsDropDownSelector.jsx | 40 +++++++-- 4 files changed, 135 insertions(+), 58 deletions(-) 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,