feat: add search highlight/expand and "no tags" message (#799)

This change makes minor improvements in the search taxonomy UI.  It expands taxonomies that match the search and highlight the search term, and adds a "No tag found with search term '....'" message.
This commit is contained in:
Rômulo Penido
2024-02-02 12:09:30 -03:00
committed by GitHub
parent 49d4fd44a3
commit 24c48bc3ea
9 changed files with 201 additions and 107 deletions

View File

@@ -123,7 +123,8 @@ const ContentTagsCollapsible = ({ contentId, taxonomyAndTagsData }) => {
const handleSearchChange = React.useCallback((value) => {
if (value === '') {
// No need to debounce when search term cleared
// No need to debounce when search term cleared. Clear debounce function
handleSearch.cancel();
setSearchTerm('');
} else {
handleSearch(value);

View File

@@ -18,12 +18,12 @@ jest.mock('./data/apiHooks', () => ({
})),
useTaxonomyTagsData: jest.fn(() => ({
hasMorePages: false,
tagPages: [{
tagPages: {
isLoading: true,
isError: false,
canAddTag: false,
data: [],
}],
},
})),
}));
@@ -85,7 +85,7 @@ describe('<ContentTagsCollapsible />', () => {
useTaxonomyTagsData.mockReturnValue({
hasMorePages: false,
canAddTag: false,
tagPages: [{
tagPages: {
isLoading: false,
isError: false,
data: [{
@@ -119,7 +119,7 @@ describe('<ContentTagsCollapsible />', () => {
canChangeTag: false,
canDeleteTag: false,
}],
}],
},
});
}

View File

@@ -1,5 +1,5 @@
// @ts-check
import React, { useState, useCallback } from 'react';
import React, { useEffect, useState, useCallback } from 'react';
import {
SelectableBox,
Icon,
@@ -14,6 +14,33 @@ import './ContentTagsDropDownSelector.scss';
import { useTaxonomyTagsData } from './data/apiHooks';
const HighlightedText = ({ text, highlight }) => {
if (!highlight) {
return <span>{text}</span>;
}
const parts = text.split(new RegExp(`(${highlight})`, 'gi'));
return (
<span>
{parts.map((part, index) => (
// eslint-disable-next-line react/no-array-index-key -- using index because part is not unique
<React.Fragment key={index}>
{part.toLowerCase() === highlight.toLowerCase() ? <b>{part}</b> : part}
</React.Fragment>
))}
</span>
);
};
HighlightedText.propTypes = {
text: PropTypes.string.isRequired,
highlight: PropTypes.string,
};
HighlightedText.defaultProps = {
highlight: '',
};
const ContentTagsDropDownSelector = ({
taxonomyId, level, lineage, tagsTree, searchTerm,
}) => {
@@ -22,7 +49,7 @@ const ContentTagsDropDownSelector = ({
// 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 [dropdownStates, setDropdownStates] = useState(/** type Record<string, boolean> */ {});
const isOpen = (tagValue) => dropdownStates[tagValue];
const [numPages, setNumPages] = useState(1);
@@ -38,6 +65,23 @@ const ContentTagsDropDownSelector = ({
setNumPages(1);
}
useEffect(() => {
if (tagPages.isSuccess) {
if (searchTerm) {
const expandAll = tagPages.data.reduce(
(acc, tagData) => ({
...acc,
[tagData.value]: !!tagData.childCount,
}),
{},
);
setDropdownStates(expandAll);
} else {
setDropdownStates({});
}
}
}, [searchTerm, tagPages.isSuccess]);
const clickAndEnterHandler = (tagValue) => {
// This flips the state of the dropdown at index false (closed) -> true (open)
// and vice versa. Initially they are undefined which is falsy.
@@ -60,69 +104,62 @@ const ContentTagsDropDownSelector = ({
return (
<div style={{ marginLeft: `${level * 1 }rem` }}>
{tagPages.map((tagPage, pageNum) => (
// Array index represents the page number
// eslint-disable-next-line react/no-array-index-key
<React.Fragment key={`tag-page-${pageNum}`}>
{tagPage.isLoading ? (
<div className="d-flex justify-content-center align-items-center flex-row">
<Spinner
animation="border"
size="xl"
screenReaderText={intl.formatMessage(messages.loadingTagsDropdownMessage)}
/>
</div>
) : null }
{tagPage.isError ? 'Error...' : null /* TODO: show a proper error message */}
{tagPages.isLoading ? (
<div className="d-flex justify-content-center align-items-center flex-row">
<Spinner
animation="border"
size="xl"
screenReaderText={intl.formatMessage(messages.loadingTagsDropdownMessage)}
/>
</div>
) : null }
{tagPages.isError ? 'Error...' : null /* TODO: show a proper error message */}
{tagPage.data?.map((tagData) => (
<React.Fragment key={tagData.value}>
<div
className="d-flex flex-row"
style={{
minHeight: '44px',
}}
{tagPages.data?.map((tagData) => (
<React.Fragment key={tagData.value}>
<div
className="d-flex flex-row"
style={{
minHeight: '44px',
}}
>
<div className="d-flex">
<SelectableBox
inputHidden={false}
type="checkbox"
className="d-flex align-items-center taxonomy-tags-selectable-box"
aria-label={intl.formatMessage(messages.taxonomyTagsCheckboxAriaLabel, { tag: tagData.value })}
data-selectable-box="taxonomy-tags"
value={[...lineage, tagData.value].map(t => encodeURIComponent(t)).join(',')}
isIndeterminate={isImplicit(tagData)}
disabled={isImplicit(tagData)}
>
<div className="d-flex">
<SelectableBox
inputHidden={false}
type="checkbox"
className="d-flex align-items-center taxonomy-tags-selectable-box"
aria-label={intl.formatMessage(messages.taxonomyTagsCheckboxAriaLabel, { tag: tagData.value })}
data-selectable-box="taxonomy-tags"
value={[...lineage, tagData.value].map(t => encodeURIComponent(t)).join(',')}
isIndeterminate={isImplicit(tagData)}
disabled={isImplicit(tagData)}
>
{tagData.value}
</SelectableBox>
{ tagData.childCount > 0
&& (
<div className="d-flex align-items-center taxonomy-tags-arrow-drop-down">
<Icon
src={isOpen(tagData.value) ? ArrowDropUp : ArrowDropDown}
onClick={() => clickAndEnterHandler(tagData.value)}
tabIndex="0"
onKeyPress={(event) => (event.key === 'Enter' ? clickAndEnterHandler(tagData.value) : null)}
/>
</div>
)}
</div>
<HighlightedText text={tagData.value} highlight={searchTerm} />
</SelectableBox>
{ tagData.childCount > 0
&& (
<div className="d-flex align-items-center taxonomy-tags-arrow-drop-down">
<Icon
src={isOpen(tagData.value) ? ArrowDropUp : ArrowDropDown}
onClick={() => clickAndEnterHandler(tagData.value)}
tabIndex="0"
onKeyPress={(event) => (event.key === 'Enter' ? clickAndEnterHandler(tagData.value) : null)}
/>
</div>
)}
</div>
</div>
</div>
{ tagData.childCount > 0 && isOpen(tagData.value) && (
<ContentTagsDropDownSelector
taxonomyId={taxonomyId}
level={level + 1}
lineage={[...lineage, tagData.value]}
tagsTree={tagsTree}
searchTerm={searchTerm}
/>
)}
</React.Fragment>
))}
{ tagData.childCount > 0 && isOpen(tagData.value) && (
<ContentTagsDropDownSelector
taxonomyId={taxonomyId}
level={level + 1}
lineage={[...lineage, tagData.value]}
tagsTree={tagsTree}
searchTerm={searchTerm}
/>
)}
</React.Fragment>
))}
@@ -141,6 +178,12 @@ const ContentTagsDropDownSelector = ({
)
: null}
{ tagPages.data.length === 0 && !tagPages.isLoading && (
<div className="d-flex justify-content-center muted-text">
<FormattedMessage {...messages.noTagsFoundMessage} values={{ searchTerm }} />
</div>
)}
</div>
);
};

View File

@@ -13,11 +13,11 @@ import { useTaxonomyTagsData } from './data/apiHooks';
jest.mock('./data/apiHooks', () => ({
useTaxonomyTagsData: jest.fn(() => ({
hasMorePages: false,
tagPages: [{
tagPages: {
isLoading: true,
isError: false,
data: [],
}],
},
})),
}));
@@ -70,7 +70,7 @@ describe('<ContentTagsDropDownSelector />', () => {
it('should render taxonomy tags drop down selector with no sub tags', async () => {
useTaxonomyTagsData.mockReturnValue({
hasMorePages: false,
tagPages: [{
tagPages: {
isLoading: false,
isError: false,
data: [{
@@ -82,7 +82,7 @@ describe('<ContentTagsDropDownSelector />', () => {
id: 12345,
subTagsUrl: null,
}],
}],
},
});
await act(async () => {
@@ -104,7 +104,7 @@ describe('<ContentTagsDropDownSelector />', () => {
it('should render taxonomy tags drop down selector with sub tags', async () => {
useTaxonomyTagsData.mockReturnValue({
hasMorePages: false,
tagPages: [{
tagPages: {
isLoading: false,
isError: false,
data: [{
@@ -116,7 +116,7 @@ describe('<ContentTagsDropDownSelector />', () => {
id: 12345,
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=Tag%202',
}],
}],
},
});
await act(async () => {
@@ -137,7 +137,7 @@ describe('<ContentTagsDropDownSelector />', () => {
it('should expand on click taxonomy tags drop down selector with sub tags', async () => {
useTaxonomyTagsData.mockReturnValueOnce({
hasMorePages: false,
tagPages: [{
tagPages: {
isLoading: false,
isError: false,
data: [{
@@ -149,7 +149,7 @@ describe('<ContentTagsDropDownSelector />', () => {
id: 12345,
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=Tag%202',
}],
}],
},
});
await act(async () => {
@@ -177,7 +177,7 @@ describe('<ContentTagsDropDownSelector />', () => {
// Mock useTaxonomyTagsData again since it gets called in the recursive call
useTaxonomyTagsData.mockReturnValueOnce({
hasMorePages: false,
tagPages: [{
tagPages: {
isLoading: false,
isError: false,
data: [{
@@ -189,7 +189,7 @@ describe('<ContentTagsDropDownSelector />', () => {
id: 12346,
subTagsUrl: null,
}],
}],
},
});
// Expand the dropdown to see the subtags selectors
@@ -205,7 +205,7 @@ describe('<ContentTagsDropDownSelector />', () => {
it('should expand on enter key taxonomy tags drop down selector with sub tags', async () => {
useTaxonomyTagsData.mockReturnValueOnce({
hasMorePages: false,
tagPages: [{
tagPages: {
isLoading: false,
isError: false,
data: [{
@@ -217,7 +217,7 @@ describe('<ContentTagsDropDownSelector />', () => {
id: 12345,
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=Tag%202',
}],
}],
},
});
await act(async () => {
@@ -245,7 +245,7 @@ describe('<ContentTagsDropDownSelector />', () => {
// Mock useTaxonomyTagsData again since it gets called in the recursive call
useTaxonomyTagsData.mockReturnValueOnce({
hasMorePages: false,
tagPages: [{
tagPages: {
isLoading: false,
isError: false,
data: [{
@@ -257,7 +257,7 @@ describe('<ContentTagsDropDownSelector />', () => {
id: 12346,
subTagsUrl: null,
}],
}],
},
});
// Expand the dropdown to see the subtags selectors
@@ -273,9 +273,10 @@ describe('<ContentTagsDropDownSelector />', () => {
it('should render taxonomy tags drop down selector and change search term', async () => {
useTaxonomyTagsData.mockReturnValueOnce({
hasMorePages: false,
tagPages: [{
tagPages: {
isLoading: false,
isError: false,
isSuccess: true,
data: [{
value: 'Tag 1',
externalId: null,
@@ -285,7 +286,7 @@ describe('<ContentTagsDropDownSelector />', () => {
id: 12345,
subTagsUrl: null,
}],
}],
},
});
const initalSearchTerm = 'test 1';
@@ -316,6 +317,52 @@ describe('<ContentTagsDropDownSelector />', () => {
await waitFor(() => {
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, updatedSearchTerm);
});
// Clean search term
const cleanSearchTerm = '';
rerender(<ContentTagsDropDownSelectorComponent
key={`selector-${data.taxonomyId}`}
taxonomyId={data.taxonomyId}
level={data.level}
tagsTree={data.tagsTree}
searchTerm={cleanSearchTerm}
/>);
await waitFor(() => {
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, cleanSearchTerm);
});
});
});
it('should render "noTag" message if search doesnt return taxonomies', async () => {
useTaxonomyTagsData.mockReturnValueOnce({
hasMorePages: false,
tagPages: {
isLoading: false,
isError: false,
isSuccess: true,
data: [],
},
});
const searchTerm = 'uncommon search term';
await act(async () => {
const { getByText } = render(
<ContentTagsDropDownSelectorComponent
key={`selector-${data.taxonomyId}`}
taxonomyId={data.taxonomyId}
level={data.level}
tagsTree={data.tagsTree}
searchTerm={searchTerm}
/>,
);
await waitFor(() => {
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, searchTerm);
});
const message = `No tags found with the search term "${searchTerm}"`;
expect(getByText(message)).toBeInTheDocument();
});
});
});

View File

@@ -75,8 +75,8 @@ export async function getContentData(contentId) {
* @returns {Promise<import("./types.mjs").ContentTaxonomyTagsData>}
*/
export async function updateContentTaxonomyTags(contentId, taxonomyId, tags) {
const url = getContentTaxonomyTagsApiUrl(contentId);
const params = { taxonomy: taxonomyId };
const { data } = await getAuthenticatedHttpClient().put(url, { tags }, { params });
let url = getContentTaxonomyTagsApiUrl(contentId);
url = `${url}&taxonomy=${taxonomyId}`;
const { data } = await getAuthenticatedHttpClient().put(url, { tags });
return camelCaseObject(data[contentId]);
}

View File

@@ -110,11 +110,10 @@ describe('content tags drawer api calls', () => {
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
const taxonomyId = 3;
const tags = ['flat taxonomy tag 100', 'flat taxonomy tag 3856'];
axiosMock.onPut(`${getContentTaxonomyTagsApiUrl(contentId)}`).reply(200, updateContentTaxonomyTagsMock);
axiosMock.onPut(`${getContentTaxonomyTagsApiUrl(contentId)}&taxonomy=${taxonomyId}`).reply(200, updateContentTaxonomyTagsMock);
const result = await updateContentTaxonomyTags(contentId, taxonomyId, tags);
expect(axiosMock.history.put[0].url).toEqual(`${getContentTaxonomyTagsApiUrl(contentId)}`);
expect(axiosMock.history.put[0].params).toEqual({ taxonomy: taxonomyId });
expect(axiosMock.history.put[0].url).toEqual(`${getContentTaxonomyTagsApiUrl(contentId)}&taxonomy=${taxonomyId}`);
expect(result).toEqual(updateContentTaxonomyTagsMock[contentId]);
});
});

View File

@@ -22,14 +22,6 @@ import {
* @param {string|null} parentTag The tag whose children we're loading, if any
* @param {string} searchTerm The term passed in to perform search on tags
* @param {number} numPages How many pages of tags to load at this level
* @returns {{
* hasMorePages: boolean,
* tagPages: {
* isLoading: boolean,
* isError: boolean,
* data: TagData[],
* }[],
* }}
*/
export const useTaxonomyTagsData = (taxonomyId, parentTag = null, numPages = 1, searchTerm = '') => {
const queryClient = useQueryClient();
@@ -53,13 +45,10 @@ export const useTaxonomyTagsData = (taxonomyId, parentTag = null, numPages = 1,
const hasMorePages = numPages < totalPages;
const tagPages = useMemo(() => {
/** @type { { isLoading: boolean, isError: boolean, data: TagData[] }[] } */
const newTags = [];
// Pre-load desendants if possible
const preLoadedData = new Map();
dataPages.forEach(result => {
const newTags = dataPages.map(result => {
/** @type {TagData[]} */
const simplifiedTagsList = [];
@@ -73,7 +62,7 @@ export const useTaxonomyTagsData = (taxonomyId, parentTag = null, numPages = 1,
}
});
newTags.push({ ...result, data: simplifiedTagsList });
return { ...result, data: simplifiedTagsList };
});
// Store the pre-loaded descendants into the query cache:
@@ -95,7 +84,14 @@ export const useTaxonomyTagsData = (taxonomyId, parentTag = null, numPages = 1,
return newTags;
}, [dataPages]);
return { hasMorePages, tagPages };
const flatTagPages = {
isLoading: tagPages.some(page => page.isLoading),
isError: tagPages.some(page => page.isError),
isSuccess: tagPages.every(page => page.isSuccess),
data: tagPages.flatMap(page => page.data),
};
return { hasMorePages, tagPages: flatTagPages };
};
/**

View File

@@ -67,9 +67,12 @@ describe('useTaxonomyTagsData', () => {
],
};
useQueries.mockReturnValue([
{ data: mockData, isLoading: false, isError: false },
]);
useQueries.mockReturnValue([{
data: mockData,
isLoading: false,
isError: false,
isSuccess: true,
}]);
const { result } = renderHook(() => useTaxonomyTagsData(taxonomyId));
@@ -83,10 +86,11 @@ describe('useTaxonomyTagsData', () => {
expect(result.current.hasMorePages).toEqual(false);
// Only includes the first 2 tags because the other 2 would be
// in the nested dropdown
expect(result.current.tagPages).toEqual([
expect(result.current.tagPages).toEqual(
{
isLoading: false,
isError: false,
isSuccess: true,
data: [
{
value: 'tag 1',
@@ -108,7 +112,7 @@ describe('useTaxonomyTagsData', () => {
},
],
},
]);
);
});
});

View File

@@ -21,6 +21,10 @@ const messages = defineMessages({
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.load-more-tags.button',
defaultMessage: 'Load more',
},
noTagsFoundMessage: {
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.no-tags-found',
defaultMessage: 'No tags found with the search term "{searchTerm}"',
},
taxonomyTagsCheckboxAriaLabel: {
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.selectable-box.aria.label',
defaultMessage: '{tag} checkbox',