Skip to content

Commit

Permalink
feat: Implement commit/cancel staged tags
Browse files Browse the repository at this point in the history
This implements the commit functionality for staged tags, taking account
for implicit tags. This also handles the case for removing applied tags
by clicking on the "x" in the TagBubble.
  • Loading branch information
yusuf-musleh committed Feb 28, 2024
1 parent 8288893 commit da28fb9
Show file tree
Hide file tree
Showing 3 changed files with 91 additions and 41 deletions.
41 changes: 36 additions & 5 deletions src/content-tags-drawer/ContentTagsCollapsible.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Collapsible,
SelectableBox,
Button,
Spinner,
} from '@openedx/paragon';
import PropTypes from 'prop-types';
import classNames from 'classnames';
Expand All @@ -32,6 +33,7 @@ const CustomMenu = (props) => {
selectRef,
searchTerm,
value,
commitStagedTags,
} = props.selectProps;
return (
<components.Menu {...props}>
Expand Down Expand Up @@ -66,7 +68,7 @@ const CustomMenu = (props) => {
<Button
variant="tertiary"
disabled={!(value && value.length)}
onClick={() => { /* TODO: Implement this */ }}
onClick={() => { commitStagedTags(); handleStagedTagsMenuChange([]); selectRef.current?.blur(); }}
>
{ intl.formatMessage(messages.collapsibleAddStagedTagsButtonText) }
</Button>
Expand Down Expand Up @@ -103,6 +105,24 @@ CustomMenu.propTypes = {
value: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
})).isRequired,
commitStagedTags: PropTypes.func.isRequired,
}).isRequired,
};

const CustomLoadingIndicator = (props) => {
const { intl } = props.selectProps;
return (
<Spinner
animation="border"
size="xl"
screenReaderText={intl.formatMessage(messages.loadingMessage)}
/>
);
};

CustomLoadingIndicator.propTypes = {
selectProps: PropTypes.shape({
intl: intlShape.isRequired,
}).isRequired,
};

Expand Down Expand Up @@ -196,12 +216,20 @@ const ContentTagsCollapsible = ({
const selectRef = React.useRef(null);

const {
tagChangeHandler, appliedContentTagsTree, stagedContentTagsTree, contentTagsCount, checkedTags,
tagChangeHandler,
removeAppliedTagHandler,
appliedContentTagsTree,
stagedContentTagsTree,
contentTagsCount,
checkedTags,
commitStagedTags,
updateTags,
} = useContentTagsCollapsibleHelper(
contentId,
taxonomyAndTagsData,
addStagedContentTag,
removeStagedContentTag,
stagedContentTags,
);

const [searchTerm, setSearchTerm] = React.useState('');
Expand Down Expand Up @@ -235,7 +263,7 @@ const ContentTagsCollapsible = ({
);

// 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
// 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));
Expand All @@ -246,7 +274,7 @@ const ContentTagsCollapsible = ({
<div className="d-flex">
<Collapsible title={name} styling="card-lg" className="taxonomy-tags-collapsible">
<div key={taxonomyId}>
<ContentTagsTree tagsTree={appliedContentTagsTree} removeTagHandler={tagChangeHandler} />
<ContentTagsTree tagsTree={appliedContentTagsTree} removeTagHandler={removeAppliedTagHandler} />
</div>

<div className="d-flex taxonomy-tags-selector-menu">
Expand All @@ -255,14 +283,16 @@ const ContentTagsCollapsible = ({
<Select
ref={selectRef}
isMulti
isLoading={updateTags.isLoading}
isDisabled={updateTags.isLoading}
name="tags-select"
placeholder={intl.formatMessage(messages.collapsibleAddTagsPlaceholderText)}
isSearchable
className="d-flex flex-column flex-fill"
classNamePrefix="react-select"
onInputChange={handleSearchChange}
onChange={handleStagedTagsMenuChange}
components={{ Menu: CustomMenu }}
components={{ Menu: CustomMenu, LoadingIndicator: CustomLoadingIndicator }}
closeMenuOnSelect={false}
blurInputOnSelect={false}
intl={intl}
Expand All @@ -275,6 +305,7 @@ const ContentTagsCollapsible = ({
selectRef={selectRef}
searchTerm={searchTerm}
value={stagedContentTags}
commitStagedTags={commitStagedTags}
/>
)}
</div>
Expand Down
89 changes: 54 additions & 35 deletions src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,14 @@ const useContentTagsCollapsibleHelper = (
taxonomyAndTagsData,
addStagedContentTag,
removeStagedContentTag,
stagedContentTags,
) => {
const {
id, contentTags, canTagObject,
} = taxonomyAndTagsData;
// State to determine whether the tags are being updating so we can make a call
// State to determine whether an applied tag was removed so we make a call
// to the update endpoint to the reflect those changes
const [updatingTags, setUpdatingTags] = React.useState(false);
const [removingAppliedTag, setRemoveAppliedTag] = React.useState(false);
const updateTags = useContentTaxonomyTagsUpdater(contentId, id);

// Keeps track of the content objects tags count (both implicit and explicit)
Expand All @@ -100,35 +101,41 @@ const useContentTagsCollapsibleHelper = (
const [stagedContentTagsTree, setStagedContentTagsTree] = React.useState({});

// To handle checking/unchecking tags in the SelectableBox
const [checkedTags, { add, remove, clear }] = useCheckboxSetValues();
const [checkedTags, { add, remove }] = useCheckboxSetValues();

// Handles making requests to the backend when applied tags are removed
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 removes an applied tag
if (removingAppliedTag) {
setRemoveAppliedTag(false);

// Filter out staged tags from the checktags so they do not get committed
const tags = checkedTags.map(t => decodeURIComponent(t.split(',').slice(-1)));
const staged = stagedContentTags.map(t => t.label);
const remainingAppliedTags = tags.filter(t => !staged.includes(t));

updateTags.mutate({ tags: remainingAppliedTags });
}
}, [contentId, id, canTagObject, checkedTags, stagedContentTags]);

// =================================================================
// Handles making requests to the update endpoint when the staged tags need to be committed
const commitStagedTags = React.useCallback(() => {
const staged = stagedContentTags.map(t => t.label);

// TODO: Properly implement this based on feature/requirements
const stagedLineages = stagedContentTags.map(st => decodeURIComponent(st.value).split(',').slice(0, -1)).flat();

// // 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, canTagObject, checkedTags]);
// Filter out applied tags that should become implicit because a child tag was committed
const applied = contentTags.map((t) => t.value).filter(t => !stagedLineages.includes(t));

// ==================================================================
updateTags.mutate({ tags: [...applied, ...staged] });
}, [contentTags, stagedContentTags, updateTags]);

// This converts the contentTags prop to the tree structure mentioned above
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
setStagedContentTagsTree({});
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) {
Expand Down Expand Up @@ -172,6 +179,10 @@ const useContentTagsCollapsibleHelper = (
tagLineage.forEach(tag => {
const isExplicit = selectedTag === tag;

// Clear out the ancestor tags leading to newly selected tag
// as they automatically become implicit
value.push(encodeURIComponent(tag));

if (!traversal[tag]) {
traversal[tag] = {
explicit: isExplicit,
Expand All @@ -181,19 +192,11 @@ const useContentTagsCollapsibleHelper = (
};
} else {
traversal[tag].explicit = isExplicit;
}

// Clear out the ancestor tags leading to newly selected tag
// as they automatically become implicit
value.push(encodeURIComponent(tag));

if (isExplicit) {
add(value.join(','));
} else {
removeStagedContentTag(id, value.join(','));
remove(value.join(','));
}

// eslint-disable-next-line no-unused-expressions
isExplicit ? add(value.join(',')) : remove(value.join(','));
traversal = traversal[tag].children;
});
};
Expand Down Expand Up @@ -222,6 +225,9 @@ const useContentTagsCollapsibleHelper = (
// Remove tag from the SelectableBox.Set
remove(tagSelectableBoxValue);

// Remove tags from applied tags
removeTags(appliedContentTagsTree, 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 => {
Expand All @@ -233,19 +239,32 @@ const useContentTagsCollapsibleHelper = (
// Remove content tag from taxonomy's staged tags select menu
removeStagedContentTag(id, tagSelectableBoxValue);
}

// setUpdatingTags(true);
}, [
stagedContentTagsTree, setStagedContentTagsTree, addTags, removeTags, removeTags,
stagedContentTagsTree, setStagedContentTagsTree, addTags, removeTags,
id, addStagedContentTag, removeStagedContentTag,
]);

const removeAppliedTagHandler = React.useCallback((tagSelectableBoxValue) => {
const tagLineage = tagSelectableBoxValue.split(',').map(t => decodeURIComponent(t));

// Remove tag from the SelectableBox.Set
remove(tagSelectableBoxValue);

// Remove tags from applied tags
removeTags(appliedContentTagsTree, tagLineage);

setRemoveAppliedTag(true);
}, [checkedTags]);

return {
tagChangeHandler,
removeAppliedTagHandler,
appliedContentTagsTree: sortKeysAlphabetically(appliedContentTagsTree),
stagedContentTagsTree: sortKeysAlphabetically(stagedContentTagsTree),
contentTagsCount,
checkedTags,
commitStagedTags,
updateTags,
};
};

Expand Down
2 changes: 1 addition & 1 deletion src/content-tags-drawer/TagBubble.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const TagBubble = ({

const handleClick = React.useCallback(() => {
if (!implicit && canRemove) {
removeTagHandler(lineage.join(','), false);
removeTagHandler(lineage.join(','));
}
}, [implicit, lineage, canRemove, removeTagHandler]);

Expand Down

0 comments on commit da28fb9

Please sign in to comment.