Skip to content

Commit

Permalink
feat: Split up applied & staged content tags trees
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
yusuf-musleh committed Feb 20, 2024
1 parent 44fa6cd commit b3aa084
Show file tree
Hide file tree
Showing 4 changed files with 129 additions and 58 deletions.
62 changes: 49 additions & 13 deletions src/content-tags-drawer/ContentTagsCollapsible.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from '@openedx/paragon';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { cloneDeep } from 'lodash';

Check failure on line 11 in src/content-tags-drawer/ContentTagsCollapsible.jsx

View workflow job for this annotation

GitHub Actions / tests

'/home/runner/work/frontend-app-course-authoring/frontend-app-course-authoring/node_modules/lodash/lodash.js' imported multiple times
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';

Check failure on line 12 in src/content-tags-drawer/ContentTagsCollapsible.jsx

View workflow job for this annotation

GitHub Actions / tests

'FormattedMessage' is defined but never used
import { debounce } from 'lodash';

Check failure on line 13 in src/content-tags-drawer/ContentTagsCollapsible.jsx

View workflow job for this annotation

GitHub Actions / tests

'/home/runner/work/frontend-app-course-authoring/frontend-app-course-authoring/node_modules/lodash/lodash.js' imported multiple times
import messages from './messages';
Expand All @@ -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,

Check failure on line 25 in src/content-tags-drawer/ContentTagsCollapsible.jsx

View workflow job for this annotation

GitHub Actions / tests

'selectProps.intl' is missing in props validation
handleSelectableBoxChange,

Check failure on line 26 in src/content-tags-drawer/ContentTagsCollapsible.jsx

View workflow job for this annotation

GitHub Actions / tests

'selectProps.handleSelectableBoxChange' is missing in props validation
checkedTags,

Check failure on line 27 in src/content-tags-drawer/ContentTagsCollapsible.jsx

View workflow job for this annotation

GitHub Actions / tests

'selectProps.checkedTags' is missing in props validation
taxonomyId,

Check failure on line 28 in src/content-tags-drawer/ContentTagsCollapsible.jsx

View workflow job for this annotation

GitHub Actions / tests

'selectProps.taxonomyId' is missing in props validation
appliedContentTagsTree,

Check failure on line 29 in src/content-tags-drawer/ContentTagsCollapsible.jsx

View workflow job for this annotation

GitHub Actions / tests

'selectProps.appliedContentTagsTree' is missing in props validation
stagedContentTagsTree,

Check failure on line 30 in src/content-tags-drawer/ContentTagsCollapsible.jsx

View workflow job for this annotation

GitHub Actions / tests

'selectProps.stagedContentTagsTree' is missing in props validation
searchTerm,
} = props.selectProps;
return (
<components.Menu {...props}>
<div className="bg-white p-3 shadow">
Expand All @@ -38,7 +47,8 @@ const CustomMenu = (props) => {
key={`selector-${taxonomyId}`}
taxonomyId={taxonomyId}
level={0}
tagsTree={tagsTree}
appliedContentTagsTree={appliedContentTagsTree}
stagedContentTagsTree={stagedContentTagsTree}
searchTerm={searchTerm}
/>
</SelectableBox.Set>
Expand All @@ -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 */

Expand Down Expand Up @@ -123,18 +135,25 @@ const CustomMenu = (props) => {
*
* @param {Object} props - The component props.
* @param {string} props.contentId - Id of the content object
* @param {Array<Object>} 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('');
Expand All @@ -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, stagedContentTags, tagChangeHandler]);

return (
<div className="d-flex">
<Collapsible title={name} styling="card-lg" className="taxonomy-tags-collapsible">
<div key={taxonomyId}>
<ContentTagsTree tagsTree={tagsTree} removeTagHandler={tagChangeHandler} />
<ContentTagsTree tagsTree={appliedContentTagsTree} removeTagHandler={tagChangeHandler} />
</div>

<div className="d-flex taxonomy-tags-selector-menu">
Expand All @@ -174,22 +213,18 @@ 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}
intl={intl}
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
/>
)}
</div>
Expand Down Expand Up @@ -226,6 +261,7 @@ ContentTagsCollapsible.propTypes = {
})).isRequired,
addStagedContentTag: PropTypes.func.isRequired,
removeStagedContentTag: PropTypes.func.isRequired,
setStagedTags: PropTypes.func.isRequired,
};

export default ContentTagsCollapsible;
75 changes: 38 additions & 37 deletions src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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];
Expand Down Expand Up @@ -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();
Expand All @@ -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,
Expand Down Expand Up @@ -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 => {
Expand All @@ -184,7 +182,7 @@ const useContentTagsCollapsibleHelper = (
} else {
traversal[tag].explicit = isExplicit;
if (!isExplicit) {
removeStagedContentTag(taxonomyId, tag);
removeStagedContentTag(id, tag);
}
}

Expand All @@ -202,12 +200,12 @@ const useContentTagsCollapsibleHelper = (
const tagLineage = tagSelectableBoxValue.split(',').map(t => decodeURIComponent(t));
const selectedTag = tagLineage.slice(-1)[0];

const addedTree = { ...addedContentTags };
const stagedTree = { ...stagedContentTagsTree };
if (checked) {
// We "add" the tag to the SelectableBox.Set inside the addTags method
addTags(addedTree, tagLineage, selectedTag, id);
addTags(stagedTree, tagLineage, selectedTag);

// Add content tag to taxonomy's staged tags
// Add content tag to taxonomy's staged tags select menu
addStagedContentTag(
id,
{
Expand All @@ -219,24 +217,27 @@ 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
removeTags(stagedTree, tagLineage);

// Remove content tag from taxonomy's staged tags
// Remove content tag from taxonomy's staged tags select menu
removeStagedContentTag(id, selectedTag);
}

setAddedContentTags(addedTree);
setStagedContentTagsTree(stagedTree);
// 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,
};
};

Expand Down
10 changes: 10 additions & 0 deletions src/content-tags-drawer/ContentTagsDrawer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -144,6 +153,7 @@ const ContentTagsDrawer = () => {
stagedContentTags={stagedContentTags[data.id] || []}
addStagedContentTag={addStagedContentTag}
removeStagedContentTag={removeStagedContentTag}
setStagedTags={setStagedTags}
/>
<hr />
</div>
Expand Down
40 changes: 32 additions & 8 deletions src/content-tags-drawer/ContentTagsDropDownSelector.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ HighlightedText.defaultProps = {
};

const ContentTagsDropDownSelector = ({
taxonomyId, level, lineage, tagsTree, searchTerm,
taxonomyId, level, lineage, appliedContentTagsTree, stagedContentTagsTree, searchTerm,
}) => {
const intl = useIntl();

Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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)}
>
<HighlightedText text={tagData.value} highlight={searchTerm} />
</SelectableBox>
Expand All @@ -156,7 +173,8 @@ const ContentTagsDropDownSelector = ({
taxonomyId={taxonomyId}
level={level + 1}
lineage={[...lineage, tagData.value]}
tagsTree={tagsTree}
appliedContentTagsTree={appliedContentTagsTree}
stagedContentTagsTree={stagedContentTagsTree}
searchTerm={searchTerm}
/>
)}
Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit b3aa084

Please sign in to comment.