Skip to content

Commit

Permalink
feat: Add tag search in content tags drawer
Browse files Browse the repository at this point in the history
  • Loading branch information
yusuf-musleh committed Dec 3, 2023
1 parent d8ac46a commit 21db528
Show file tree
Hide file tree
Showing 5 changed files with 95 additions and 23 deletions.
31 changes: 30 additions & 1 deletion src/content-tags-drawer/ContentTagsCollapsible.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import {
ModalPopup,
useToggle,
useCheckboxSetValues,
SearchField,
} from '@edx/paragon';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { debounce } from './utils';
import messages from './messages';
import './ContentTagsCollapsible.scss';

Expand Down Expand Up @@ -186,6 +188,8 @@ const ContentTagsCollapsible = ({ contentId, taxonomyAndTagsData, editable }) =>
const [isOpen, open, close] = useToggle(false);
const [addTagsButtonRef, setAddTagsButtonRef] = React.useState(null);

const [searchTerm, setSearchTerm] = React.useState(null);

// Keeps track of the content objects tags count (both implicit and explicit)
const [contentTagsCount, setContentTagsCount] = React.useState(0);

Expand Down Expand Up @@ -308,6 +312,25 @@ const ContentTagsCollapsible = ({ contentId, taxonomyAndTagsData, editable }) =>
tagChangeHandler(e.target.value, e.target.checked);
});

const handleSearch = debounce((term) => {
setSearchTerm(term.trim());
}, 500); // Perform search after 500ms

const handleSearchChange = React.useCallback((value) => {
if (value === '') {
// No need to debounce when search term cleared
setSearchTerm(null);
} else {
handleSearch(value);
}
});

const modalPopupOnCloseHandler = React.useCallback((event) => {
close(event);
// Clear search term
setSearchTerm(null);
});

return (
<div className="d-flex">
<Collapsible title={name} styling="card-lg" className="taxonomy-tags-collapsible">
Expand All @@ -331,7 +354,7 @@ const ContentTagsCollapsible = ({ contentId, taxonomyAndTagsData, editable }) =>
placement="bottom"
positionRef={addTagsButtonRef}
isOpen={isOpen}
onClose={close}
onClose={modalPopupOnCloseHandler}
>
<div className="bg-white p-3 shadow">

Expand All @@ -344,11 +367,17 @@ const ContentTagsCollapsible = ({ contentId, taxonomyAndTagsData, editable }) =>
onChange={handleSelectableBoxChange}
value={checkedTags}
>
<SearchField
onSubmit={() => {}}
onChange={handleSearchChange}
/>

<ContentTagsDropDownSelector
key={`selector-${id}`}
taxonomyId={id}
level={0}
tagsTree={tagsTree}
searchTerm={searchTerm}
/>
</SelectableBox.Set>
</div>
Expand Down
41 changes: 27 additions & 14 deletions src/content-tags-drawer/ContentTagsDropDownSelector.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,36 +14,47 @@ import './ContentTagsDropDownSelector.scss';
import { useTaxonomyTagsData } from './data/apiHooks';

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

// This object represents the states of the dropdowns on this level
// The keys represent the index of the dropdown with
// the value true (open) false (closed)
const [dropdownStates, setDropdownStates] = useState({});
const isOpen = (i) => dropdownStates[i];
const closeAllDropdowns = () => {
const updatedStates = { ...dropdownStates };
// eslint-disable-next-line no-return-assign
Object.keys(updatedStates).map((key) => updatedStates[key] = false);
setDropdownStates(updatedStates);
};

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 [currentPage, setCurrentPage] = useState(1);
const [prevSearchTerm, setPrevSearchTerm] = useState(searchTerm);

const isOpen = (i) => dropdownStates[i];
// Reset the page and tags state when search term changes
// and store search term to compare
if (prevSearchTerm !== searchTerm) {
setPrevSearchTerm(searchTerm);
setCurrentPage(1);
closeAllDropdowns();
setTags([]);
}

const clickAndEnterHandler = (i) => {
// This flips the state of the dropdown at index false (closed) -> true (open)
// and vice versa. Initially they are undefined which is falsy.
setDropdownStates({ ...dropdownStates, [i]: !dropdownStates[i] });
};

const { data: taxonomyTagsData, isSuccess: isTaxonomyTagsLoaded } = useTaxonomyTagsData(taxonomyId, fetchUrl);
const {
data: taxonomyTagsData,
isSuccess: isTaxonomyTagsLoaded,
} = useTaxonomyTagsData(taxonomyId, subTagsUrl, currentPage, searchTerm);

const isImplicit = (tag) => {
// Traverse the tags tree using the lineage
Expand All @@ -64,8 +75,8 @@ const ContentTagsDropDownSelector = ({
}, [isTaxonomyTagsLoaded, taxonomyTagsData]);

const loadMoreTags = useCallback(() => {
setFetchUrl(nextPage);
}, [nextPage]);
setCurrentPage(currentPage + 1);
}, [currentPage]);

return (
<>
Expand Down Expand Up @@ -139,6 +150,7 @@ const ContentTagsDropDownSelector = ({
ContentTagsDropDownSelector.defaultProps = {
subTagsUrl: undefined,
lineage: [],
searchTerm: null,
};

ContentTagsDropDownSelector.propTypes = {
Expand All @@ -152,6 +164,7 @@ ContentTagsDropDownSelector.propTypes = {
children: PropTypes.shape({}).isRequired,
}).isRequired,
).isRequired,
searchTerm: PropTypes.string,
};

export default ContentTagsDropDownSelector;
27 changes: 22 additions & 5 deletions src/content-tags-drawer/data/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,23 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';

const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getTaxonomyTagsApiUrl = (taxonomyId) => new URL(`api/content_tagging/v1/taxonomies/${taxonomyId}/tags/`, getApiBaseUrl()).href;
export const getTaxonomyTagsApiUrl = (taxonomyId, fullPathProvided, page, searchTerm) => {
let url;
if (fullPathProvided) {
url = new URL(fullPathProvided);
} else {
url = new URL(`api/content_tagging/v1/taxonomies/${taxonomyId}/tags/`, getApiBaseUrl());
}

if (page) {
url.searchParams.append('page', page);
}

if (searchTerm) {
url.searchParams.append('search_term', searchTerm);
}
return url.href;
};
export const getContentTaxonomyTagsApiUrl = (contentId) => new URL(`api/content_tagging/v1/object_tags/${contentId}/`, getApiBaseUrl()).href;
export const getContentDataApiUrl = (contentId) => new URL(`/xblock/outline/${contentId}`, getApiBaseUrl()).href;

Expand All @@ -12,12 +28,13 @@ export const getContentDataApiUrl = (contentId) => new URL(`/xblock/outline/${co
* @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
* @param {number} page The results pages number
* @param {string} searchTerm The term used to search tags
* @returns {Promise<Object>}
*/
export async function getTaxonomyTagsData(taxonomyId, fullPathProvided) {
const { data } = await getAuthenticatedHttpClient().get(
fullPathProvided ? new URL(`${fullPathProvided}`) : getTaxonomyTagsApiUrl(taxonomyId),
);
export async function getTaxonomyTagsData(taxonomyId, fullPathProvided, page, searchTerm) {
const url = getTaxonomyTagsApiUrl(taxonomyId, fullPathProvided, page, searchTerm);
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
}

Expand Down
8 changes: 5 additions & 3 deletions src/content-tags-drawer/data/apiHooks.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ import {
* @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
* @param {number} page The results page number
* @param {string} searchTerm The term passed in to perform search on tags
* @returns {import("@tanstack/react-query").UseQueryResult<import("./types.mjs").TaxonomyTagsData>}
*/
export const useTaxonomyTagsData = (taxonomyId, fullPathProvided) => (
export const useTaxonomyTagsData = (taxonomyId, fullPathProvided, page, searchTerm) => (
useQuery({
queryKey: [`taxonomyTags${ fullPathProvided || taxonomyId }`],
queryFn: () => getTaxonomyTagsData(taxonomyId, fullPathProvided),
queryKey: ['taxonomyTags', taxonomyId, fullPathProvided, page, searchTerm],
queryFn: () => getTaxonomyTagsData(taxonomyId, fullPathProvided, page, searchTerm),
})
);

Expand Down
11 changes: 11 additions & 0 deletions src/content-tags-drawer/utils.js
Original file line number Diff line number Diff line change
@@ -1,2 +1,13 @@
// eslint-disable-next-line import/prefer-default-export
export const extractOrgFromContentId = (contentId) => contentId.split('+')[0].split(':')[1];
export const debounce = (func, delay) => {
let timeoutId;

return (...args) => {
clearTimeout(timeoutId);

timeoutId = setTimeout(() => {
func.apply(this, args);
}, delay);
};
};

0 comments on commit 21db528

Please sign in to comment.