feat: Add content tags tree state + editing (#704)

This commit adds the add/remove functionality of content tags where the
state is stored and changes are updated in the backend through the API.
Changes are reflected in the UI automatically.
This commit is contained in:
Yusuf Musleh
2023-12-08 11:25:57 +03:00
committed by GitHub
parent 56ad86ee60
commit c9b73a5008
22 changed files with 996 additions and 432 deletions

View File

@@ -17,10 +17,79 @@ import ContentTagsDropDownSelector from './ContentTagsDropDownSelector';
import ContentTagsTree from './ContentTagsTree';
import useContentTagsCollapsibleHelper from './ContentTagsCollapsibleHelper';
/**
* Collapsible component that holds a Taxonomy along with Tags that belong to it.
* This includes both applied tags and tags that are available to select
* from a dropdown list.
*
* This component also handles all the logic with selecting/deselecting tags and keeps track of the
* tags tree in the state. That is used to render the Tag bubbgles as well as the populating the
* state of the tags in the dropdown selectors.
*
* The `contentTags` that is passed are consolidated and converted to a tree structure. For example:
*
* FROM:
*
* [
* {
* "value": "DNA Sequencing",
* "lineage": [
* "Science and Research",
* "Genetics Subcategory",
* "DNA Sequencing"
* ]
* },
* {
* "value": "Virology",
* "lineage": [
* "Science and Research",
* "Molecular, Cellular, and Microbiology",
* "Virology"
* ]
* }
* ]
*
* TO:
*
* {
* "Science and Research": {
* explicit: false,
* children: {
* "Genetics Subcategory": {
* explicit: false,
* children: {
* "DNA Sequencing": {
* explicit: true,
* children: {}
* }
* }
* },
* "Molecular, Cellular, and Microbiology": {
* explicit: false,
* children: {
* "Virology": {
* explicit: true,
* children: {}
* }
* }
* }
* }
* }
* };
*
*
* It also keeps track of newly added tags as they are selected in the dropdown selectors.
* They are store in the same format above, and then merged to one tree that is used as the
* source of truth for both the tag bubble and the dropdowns. They keys are order alphabetically.
*
* In the dropdowns, the value of each SelectableBox is stored along with it's lineage and is URI encoded.
* Ths is so we are able to traverse and manipulate different parts of the tree leading to it.
* Here is an example of what the value of the "Virology" tag would be:
*
* "Science%20and%20Research,Molecular%2C%20Cellular%2C%20and%20Microbiology,Virology"
* @param {string} contentId - Id of the content object
* @param {Object} taxonomyAndTagsData - Object containing Taxonomy meta data along with applied tags
* @param {number} taxonomyAndTagsData.id - id of Taxonomy
* @param {string} taxonomyAndTagsData.name - name of Taxonomy
@@ -35,36 +104,46 @@ import ContentTagsTree from './ContentTagsTree';
* @param {Object[]} taxonomyAndTagsData.contentTags - Array of taxonomy tags that are applied to the content
* @param {string} taxonomyAndTagsData.contentTags.value - Value of applied Tag
* @param {string} taxonomyAndTagsData.contentTags.lineage - Array of Tag's ancestors sorted (ancestor -> tag)
* @param {boolean} editable - Whether the tags can be edited
*/
const ContentTagsCollapsible = ({ taxonomyAndTagsData }) => {
const ContentTagsCollapsible = ({ contentId, taxonomyAndTagsData, editable }) => {
const intl = useIntl();
const { id, name } = taxonomyAndTagsData;
const {
id, name, contentTags,
} = taxonomyAndTagsData;
tagChangeHandler, tagsTree, contentTagsCount, checkedTags,
} = useContentTagsCollapsibleHelper(contentId, taxonomyAndTagsData);
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = React.useState(null);
const [addTagsButtonRef, setAddTagsButtonRef] = React.useState(null);
const handleSelectableBoxChange = React.useCallback((e) => {
tagChangeHandler(e.target.value, e.target.checked);
});
return (
<div className="d-flex">
<Collapsible title={name} styling="card-lg" className="taxonomy-tags-collapsible">
<div key={id}>
<ContentTagsTree appliedContentTags={contentTags} />
<ContentTagsTree tagsTree={tagsTree} removeTagHandler={tagChangeHandler} editable={editable} />
</div>
<div className="d-flex taxonomy-tags-selector-menu">
<Button
ref={setTarget}
variant="outline-primary"
onClick={open}
>
<FormattedMessage {...messages.addTagsButtonText} />
</Button>
{editable && (
<Button
ref={setAddTagsButtonRef}
variant="outline-primary"
onClick={open}
>
<FormattedMessage {...messages.addTagsButtonText} />
</Button>
)}
</div>
<ModalPopup
hasArrow
placement="bottom"
positionRef={target}
positionRef={addTagsButtonRef}
isOpen={isOpen}
onClose={close}
>
@@ -76,11 +155,14 @@ const ContentTagsCollapsible = ({ taxonomyAndTagsData }) => {
columns={1}
ariaLabel={intl.formatMessage(messages.taxonomyTagsAriaLabel)}
className="taxonomy-tags-selectable-box-set"
onChange={handleSelectableBoxChange}
value={checkedTags}
>
<ContentTagsDropDownSelector
key={`selector-${id}`}
taxonomyId={id}
level={0}
tagsTree={tagsTree}
/>
</SelectableBox.Set>
</div>
@@ -92,11 +174,10 @@ const ContentTagsCollapsible = ({ taxonomyAndTagsData }) => {
variant="light"
pill
className={classNames('align-self-start', 'mt-3', {
// eslint-disable-next-line quote-props
'invisible': contentTags.length === 0,
invisible: contentTagsCount === 0,
})}
>
{contentTags.length}
{contentTagsCount}
</Badge>
</div>
</div>
@@ -104,6 +185,7 @@ const ContentTagsCollapsible = ({ taxonomyAndTagsData }) => {
};
ContentTagsCollapsible.propTypes = {
contentId: PropTypes.string.isRequired,
taxonomyAndTagsData: PropTypes.shape({
id: PropTypes.number,
name: PropTypes.string,
@@ -112,6 +194,7 @@ ContentTagsCollapsible.propTypes = {
lineage: PropTypes.arrayOf(PropTypes.string),
})),
}).isRequired,
editable: PropTypes.bool.isRequired,
};
export default ContentTagsCollapsible;

View File

@@ -22,3 +22,7 @@
overflow-y: scroll;
max-height: 20rem;
}
.pgn__modal-popup__arrow {
visibility: hidden;
}

View File

@@ -1,37 +1,59 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { act, render } from '@testing-library/react';
import {
act,
render,
fireEvent,
waitFor,
} from '@testing-library/react';
import PropTypes from 'prop-types';
import ContentTagsCollapsible from './ContentTagsCollapsible';
import messages from './messages';
import { useTaxonomyTagsData } from './data/apiHooks';
jest.mock('./data/apiHooks', () => ({
useTaxonomyTagsDataResponse: jest.fn(),
useIsTaxonomyTagsDataLoaded: jest.fn(),
useContentTaxonomyTagsUpdater: jest.fn(() => ({
isError: false,
mutate: jest.fn(),
})),
useTaxonomyTagsData: jest.fn(() => ({
isSuccess: false,
data: {},
})),
}));
const data = {
id: 123,
name: 'Taxonomy 1',
contentTags: [
{
value: 'Tag 1',
lineage: ['Tag 1'],
},
{
value: 'Tag 2',
lineage: ['Tag 2'],
},
],
contentId: 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab',
taxonomyAndTagsData: {
id: 123,
name: 'Taxonomy 1',
contentTags: [
{
value: 'Tag 1',
lineage: ['Tag 1'],
},
{
value: 'Tag 1.1',
lineage: ['Tag 1', 'Tag 1.1'],
},
{
value: 'Tag 2',
lineage: ['Tag 2'],
},
],
},
editable: true,
};
const ContentTagsCollapsibleComponent = ({ taxonomyAndTagsData }) => (
const ContentTagsCollapsibleComponent = ({ contentId, taxonomyAndTagsData, editable }) => (
<IntlProvider locale="en" messages={{}}>
<ContentTagsCollapsible taxonomyAndTagsData={taxonomyAndTagsData} />
<ContentTagsCollapsible contentId={contentId} taxonomyAndTagsData={taxonomyAndTagsData} editable={editable} />
</IntlProvider>
);
ContentTagsCollapsibleComponent.propTypes = {
contentId: PropTypes.string.isRequired,
taxonomyAndTagsData: PropTypes.shape({
id: PropTypes.number,
name: PropTypes.string,
@@ -40,22 +62,155 @@ ContentTagsCollapsibleComponent.propTypes = {
lineage: PropTypes.arrayOf(PropTypes.string),
})),
}).isRequired,
editable: PropTypes.bool.isRequired,
};
describe('<ContentTagsCollapsible />', () => {
it('should render taxonomy tags data along content tags number badge', async () => {
await act(async () => {
const { container, getByText } = render(<ContentTagsCollapsibleComponent taxonomyAndTagsData={data} />);
const { container, getByText } = render(
<ContentTagsCollapsibleComponent
contentId={data.contentId}
taxonomyAndTagsData={data.taxonomyAndTagsData}
editable={data.editable}
/>,
);
expect(getByText('Taxonomy 1')).toBeInTheDocument();
expect(container.getElementsByClassName('badge').length).toBe(1);
expect(getByText('2')).toBeInTheDocument();
expect(getByText('3')).toBeInTheDocument();
});
});
it('should render new tags as they are checked in the dropdown', async () => {
useTaxonomyTagsData.mockReturnValue({
isSuccess: true,
data: {
results: [{
value: 'Tag 1',
subTagsUrl: null,
}, {
value: 'Tag 2',
subTagsUrl: null,
}, {
value: 'Tag 3',
subTagsUrl: null,
}],
},
});
await act(async () => {
const { container, getByText, getAllByText } = render(
<ContentTagsCollapsibleComponent
contentId={data.contentId}
taxonomyAndTagsData={data.taxonomyAndTagsData}
editable={data.editable}
/>,
);
// Expand the Taxonomy to view applied tags and "Add tags" button
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
await act(async () => {
fireEvent.click(expandToggle);
});
// Click on "Add tags" button to open dropdown to select new tags
const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage);
await act(async () => {
fireEvent.click(addTagsButton);
});
// Wait for the dropdown selector for tags to open,
// Tag 3 should only appear there
await waitFor(() => {
expect(getByText('Tag 3')).toBeInTheDocument();
expect(getAllByText('Tag 3').length === 1);
});
const tag3 = getByText('Tag 3');
await act(async () => {
fireEvent.click(tag3);
});
// After clicking on Tag 3, it should also appear in amongst
// the tag bubbles in the tree
await waitFor(() => {
expect(getAllByText('Tag 3').length === 2);
});
});
});
it('should remove tag when they are unchecked in the dropdown', async () => {
useTaxonomyTagsData.mockReturnValue({
isSuccess: true,
data: {
results: [{
value: 'Tag 1',
subTagsUrl: null,
}, {
value: 'Tag 2',
subTagsUrl: null,
}, {
value: 'Tag 3',
subTagsUrl: null,
}],
},
});
await act(async () => {
const { container, getByText, getAllByText } = render(
<ContentTagsCollapsibleComponent
contentId={data.contentId}
taxonomyAndTagsData={data.taxonomyAndTagsData}
editable={data.editable}
/>,
);
// Expand the Taxonomy to view applied tags and "Add tags" button
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
await act(async () => {
fireEvent.click(expandToggle);
});
// Check that Tag 2 appears in tag bubbles
await waitFor(() => {
expect(getByText('Tag 2')).toBeInTheDocument();
});
// Click on "Add tags" button to open dropdown to select new tags
const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage);
await act(async () => {
fireEvent.click(addTagsButton);
});
// Wait for the dropdown selector for tags to open,
// Tag 3 should only appear there, (i.e. the dropdown is open, since Tag 3 is not applied)
await waitFor(() => {
expect(getByText('Tag 3')).toBeInTheDocument();
});
// Get the Tag 2 checkbox and click on it
const tag2 = getAllByText('Tag 2')[1];
await act(async () => {
fireEvent.click(tag2);
});
// After clicking on Tag 2, it should be removed from
// the tag bubbles in so only the one in the dropdown appears
expect(getAllByText('Tag 2').length === 1);
});
});
it('should render taxonomy tags data without tags number badge', async () => {
data.contentTags = [];
const updatedData = { ...data };
updatedData.taxonomyAndTagsData.contentTags = [];
await act(async () => {
const { container, getByText } = render(<ContentTagsCollapsibleComponent taxonomyAndTagsData={data} />);
const { container, getByText } = render(
<ContentTagsCollapsibleComponent
contentId={updatedData.contentId}
taxonomyAndTagsData={updatedData.taxonomyAndTagsData}
editable={updatedData.editable}
/>,
);
expect(getByText('Taxonomy 1')).toBeInTheDocument();
expect(container.getElementsByClassName('invisible').length).toBe(1);
});

View File

@@ -0,0 +1,206 @@
import React from 'react';
import { useCheckboxSetValues } from '@edx/paragon';
import { cloneDeep } from 'lodash';
import { useContentTaxonomyTagsUpdater } from './data/apiHooks';
/**
* Util function that consolidates two tag trees into one, sorting the keys in
* alphabetical order.
*
* @param {object} tree1 - first tag tree
* @param {object} tree2 - second tag tree
* @returns {object} merged tree containing both tree1 and tree2
*/
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];
if (destinationValue && sourceValue && typeof destinationValue === 'object' && typeof sourceValue === 'object') {
mergeRecursively(destinationValue, sourceValue);
} else {
// eslint-disable-next-line no-param-reassign
destination[key] = cloneDeep(sourceValue);
}
});
};
mergeRecursively(mergedTree, tree2);
return sortKeysAlphabetically(mergedTree);
};
/**
* Util function that removes the tag along with its ancestors if it was
* the only explicit child tag.
*
* @param {object} tree - tag tree to remove the tag from
* @param {string[]} tagsToRemove - full lineage of tag to remove.
* eg: ['grand parent', 'parent', 'tag']
*/
const removeTags = (tree, tagsToRemove) => {
if (!tree || !tagsToRemove.length) {
return;
}
const key = tagsToRemove[0];
if (tree[key]) {
removeTags(tree[key].children, tagsToRemove.slice(1));
if (Object.keys(tree[key].children).length === 0 && (tree[key].explicit === false || tagsToRemove.length === 1)) {
// eslint-disable-next-line no-param-reassign
delete tree[key];
}
}
};
/*
* Handles all the underlying logic for the ContentTagsCollapsible component
*/
const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => {
const {
id, contentTags,
} = taxonomyAndTagsData;
// State to determine whether the tags are being updating so we can make a call
// to the update endpoint to the reflect those changes
const [updatingTags, setUpdatingTags] = React.useState(false);
const updateTags = useContentTaxonomyTagsUpdater(contentId, id);
// Keeps track of the content objects tags count (both implicit and explicit)
const [contentTagsCount, setContentTagsCount] = React.useState(0);
// Keeps track of the tree structure for tags that are add by selecting/unselecting
// tags in the dropdowns.
const [addedContentTags, setAddedContentTags] = React.useState({});
// To handle checking/unchecking tags in the SelectableBox
const [checkedTags, { add, remove, clear }] = useCheckboxSetValues();
// 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, checkedTags]);
// This converts the contentTags prop to the tree structure mentioned above
const appliedContentTags = 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({});
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) {
updateTags.reset();
}
const resultTree = {};
contentTags.forEach(item => {
let currentLevel = resultTree;
item.lineage.forEach((key, index) => {
if (!currentLevel[key]) {
const isExplicit = index === item.lineage.length - 1;
currentLevel[key] = {
explicit: isExplicit,
children: {},
};
// Populating the SelectableBox with "selected" (explicit) tags
const value = item.lineage.map(l => encodeURIComponent(l)).join(',');
// eslint-disable-next-line no-unused-expressions
isExplicit ? add(value) : remove(value);
contentTagsCounter += 1;
}
currentLevel = currentLevel[key].children;
});
});
setContentTagsCount(contentTagsCounter);
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) => {
const value = [];
let traversal = tree;
tagLineage.forEach(tag => {
const isExplicit = selectedTag === tag;
if (!traversal[tag]) {
traversal[tag] = { explicit: isExplicit, children: {} };
} else {
traversal[tag].explicit = isExplicit;
}
// Clear out the ancestor tags leading to newly selected tag
// as they automatically become implicit
value.push(encodeURIComponent(tag));
// eslint-disable-next-line no-unused-expressions
isExplicit ? add(value.join(',')) : remove(value.join(','));
traversal = traversal[tag].children;
});
};
const tagChangeHandler = React.useCallback((tagSelectableBoxValue, checked) => {
const tagLineage = tagSelectableBoxValue.split(',').map(t => decodeURIComponent(t));
const selectedTag = tagLineage.slice(-1)[0];
const addedTree = { ...addedContentTags };
if (checked) {
// We "add" the tag to the SelectableBox.Set inside the addTags method
addTags(addedTree, tagLineage, selectedTag);
} else {
// 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);
}
setAddedContentTags(addedTree);
setUpdatingTags(true);
});
return {
tagChangeHandler, tagsTree, contentTagsCount, checkedTags,
};
};
export default useContentTagsCollapsibleHelper;

View File

@@ -10,10 +10,8 @@ import messages from './messages';
import ContentTagsCollapsible from './ContentTagsCollapsible';
import { extractOrgFromContentId } from './utils';
import {
useContentTaxonomyTagsDataResponse,
useIsContentTaxonomyTagsDataLoaded,
useContentDataResponse,
useIsContentDataLoaded,
useContentTaxonomyTagsData,
useContentData,
} from './data/apiHooks';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks';
import Loading from '../generic/Loading';
@@ -24,26 +22,17 @@ const ContentTagsDrawer = () => {
const org = extractOrgFromContentId(contentId);
const useContentData = () => {
const contentData = useContentDataResponse(contentId);
const isContentDataLoaded = useIsContentDataLoaded(contentId);
return { contentData, isContentDataLoaded };
};
const useContentTaxonomyTagsData = () => {
const contentTaxonomyTagsData = useContentTaxonomyTagsDataResponse(contentId);
const isContentTaxonomyTagsLoaded = useIsContentTaxonomyTagsDataLoaded(contentId);
return { contentTaxonomyTagsData, isContentTaxonomyTagsLoaded };
};
const useTaxonomyListData = () => {
const taxonomyListData = useTaxonomyListDataResponse(org);
const isTaxonomyListLoaded = useIsTaxonomyListDataLoaded(org);
return { taxonomyListData, isTaxonomyListLoaded };
};
const { contentData, isContentDataLoaded } = useContentData();
const { contentTaxonomyTagsData, isContentTaxonomyTagsLoaded } = useContentTaxonomyTagsData();
const { data: contentData, isSuccess: isContentDataLoaded } = useContentData(contentId);
const {
data: contentTaxonomyTagsData,
isSuccess: isContentTaxonomyTagsLoaded,
} = useContentTaxonomyTagsData(contentId);
const { taxonomyListData, isTaxonomyListLoaded } = useTaxonomyListData();
const closeContentTagsDrawer = () => {
@@ -113,7 +102,8 @@ const ContentTagsDrawer = () => {
{ isTaxonomyListLoaded && isContentTaxonomyTagsLoaded
? taxonomies.map((data) => (
<div key={`taxonomy-tags-collapsible-${data.id}`}>
<ContentTagsCollapsible taxonomyAndTagsData={data} />
{/* TODO: Properly set whether tags should be editable or not based on permissions */}
<ContentTagsCollapsible contentId={contentId} taxonomyAndTagsData={data} editable />
<hr />
</div>
))

View File

@@ -4,10 +4,8 @@ import { act, render, fireEvent } from '@testing-library/react';
import ContentTagsDrawer from './ContentTagsDrawer';
import {
useContentTaxonomyTagsDataResponse,
useIsContentTaxonomyTagsDataLoaded,
useContentDataResponse,
useIsContentDataLoaded,
useContentTaxonomyTagsData,
useContentData,
} from './data/apiHooks';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks';
@@ -19,12 +17,17 @@ jest.mock('react-router-dom', () => ({
}));
jest.mock('./data/apiHooks', () => ({
useContentTaxonomyTagsDataResponse: jest.fn(),
useIsContentTaxonomyTagsDataLoaded: jest.fn(),
useContentDataResponse: jest.fn(),
useIsContentDataLoaded: jest.fn(),
useTaxonomyTagsDataResponse: jest.fn(),
useIsTaxonomyTagsDataLoaded: jest.fn(),
useContentTaxonomyTagsData: jest.fn(() => ({
isSuccess: false,
data: {},
})),
useContentData: jest.fn(() => ({
isSuccess: false,
data: {},
})),
useContentTaxonomyTagsUpdater: jest.fn(() => ({
isError: false,
})),
}));
jest.mock('../taxonomy/data/apiHooks', () => ({
@@ -45,7 +48,6 @@ describe('<ContentTagsDrawer />', () => {
});
it('shows spinner before the content data query is complete', async () => {
useIsContentDataLoaded.mockReturnValue(false);
await act(async () => {
const { getAllByRole } = render(<RootWrapper />);
const spinner = getAllByRole('status')[0];
@@ -55,7 +57,6 @@ describe('<ContentTagsDrawer />', () => {
it('shows spinner before the taxonomy tags query is complete', async () => {
useIsTaxonomyListDataLoaded.mockReturnValue(false);
useIsContentTaxonomyTagsDataLoaded.mockReturnValue(false);
await act(async () => {
const { getAllByRole } = render(<RootWrapper />);
const spinner = getAllByRole('status')[1];
@@ -64,9 +65,11 @@ describe('<ContentTagsDrawer />', () => {
});
it('shows the content display name after the query is complete', async () => {
useIsContentDataLoaded.mockReturnValue(true);
useContentDataResponse.mockReturnValue({
displayName: 'Unit 1',
useContentData.mockReturnValue({
isSuccess: true,
data: {
displayName: 'Unit 1',
},
});
await act(async () => {
const { getByText } = render(<RootWrapper />);
@@ -76,36 +79,38 @@ describe('<ContentTagsDrawer />', () => {
it('shows the taxonomies data including tag numbers after the query is complete', async () => {
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useIsContentTaxonomyTagsDataLoaded.mockReturnValue(true);
useContentTaxonomyTagsDataResponse.mockReturnValue({
taxonomies: [
{
name: 'Taxonomy 1',
taxonomyId: 123,
editable: true,
tags: [
{
value: 'Tag 1',
lineage: ['Tag 1'],
},
{
value: 'Tag 2',
lineage: ['Tag 2'],
},
],
},
{
name: 'Taxonomy 2',
taxonomyId: 124,
editable: true,
tags: [
{
value: 'Tag 3',
lineage: ['Tag 3'],
},
],
},
],
useContentTaxonomyTagsData.mockReturnValue({
isSuccess: true,
data: {
taxonomies: [
{
name: 'Taxonomy 1',
taxonomyId: 123,
editable: true,
tags: [
{
value: 'Tag 1',
lineage: ['Tag 1'],
},
{
value: 'Tag 2',
lineage: ['Tag 2'],
},
],
},
{
name: 'Taxonomy 2',
taxonomyId: 124,
editable: true,
tags: [
{
value: 'Tag 3',
lineage: ['Tag 3'],
},
],
},
],
},
});
useTaxonomyListDataResponse.mockReturnValue({
results: [{

View File

@@ -1,19 +1,20 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import {
SelectableBox,
Icon,
Spinner,
Button,
} from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { ArrowDropDown, ArrowDropUp } from '@edx/paragon/icons';
import PropTypes from 'prop-types';
import messages from './messages';
import './ContentTagsDropDownSelector.scss';
import { useTaxonomyTagsDataResponse, useIsTaxonomyTagsDataLoaded } from './data/apiHooks';
import { useTaxonomyTagsData } from './data/apiHooks';
const ContentTagsDropDownSelector = ({
taxonomyId, level, subTagsUrl,
taxonomyId, level, subTagsUrl, lineage, tagsTree,
}) => {
const intl = useIntl();
// This object represents the states of the dropdowns on this level
@@ -21,6 +22,19 @@ const ContentTagsDropDownSelector = ({
// the value true (open) false (closed)
const [dropdownStates, setDropdownStates] = useState({});
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 isOpen = (i) => dropdownStates[i];
const clickAndEnterHandler = (i) => {
@@ -29,12 +43,33 @@ const ContentTagsDropDownSelector = ({
setDropdownStates({ ...dropdownStates, [i]: !dropdownStates[i] });
};
const taxonomyTagsData = useTaxonomyTagsDataResponse(taxonomyId, subTagsUrl);
const isTaxonomyTagsLoaded = useIsTaxonomyTagsDataLoaded(taxonomyId, subTagsUrl);
const { data: taxonomyTagsData, isSuccess: isTaxonomyTagsLoaded } = useTaxonomyTagsData(taxonomyId, fetchUrl);
const isImplicit = (tag) => {
// Traverse the tags tree using the lineage
let traversal = tagsTree;
lineage.forEach(t => {
// We need to decode the tag to traverse the tree since the lineage value is encoded
traversal = traversal[decodeURIComponent(t)]?.children || {};
});
return (traversal[tag.value] && !traversal[tag.value].explicit) || false;
};
useEffect(() => {
if (isTaxonomyTagsLoaded && taxonomyTagsData) {
setTags([...tags, ...taxonomyTagsData.results]);
setNextPage(taxonomyTagsData.next);
}
}, [isTaxonomyTagsLoaded, taxonomyTagsData]);
const loadMoreTags = useCallback(() => {
setFetchUrl(nextPage);
}, [nextPage]);
return (
isTaxonomyTagsLoaded && taxonomyTagsData
? taxonomyTagsData.results.map((taxonomyTag, i) => (
<>
{tags.map((taxonomyTag, i) => (
<div className="d-flex flex-column" key={`selector-div-${taxonomyTag.value}`} style={{ paddingLeft: `${level * 1}rem` }}>
<div className="d-flex">
<SelectableBox
@@ -43,6 +78,9 @@ const ContentTagsDropDownSelector = ({
className="taxonomy-tags-selectable-box"
aria-label={`${taxonomyTag.value} checkbox`}
data-selectable-box="taxonomy-tags"
value={[...lineage, encodeURIComponent(taxonomyTag.value)].join(',')}
isIndeterminate={isImplicit(taxonomyTag)}
disabled={isImplicit(taxonomyTag)}
>
{taxonomyTag.value}
</SelectableBox>
@@ -65,12 +103,27 @@ const ContentTagsDropDownSelector = ({
taxonomyId={taxonomyId}
subTagsUrl={taxonomyTag.subTagsUrl}
level={level + 1}
lineage={[...lineage, encodeURIComponent(taxonomyTag.value)]}
tagsTree={tagsTree}
/>
)}
</div>
))
: (
))}
{ nextPage && isTaxonomyTagsLoaded
? (
<Button
style={{ marginLeft: `${level * 1}rem` }}
variant="outline-primary"
onClick={loadMoreTags}
>
<FormattedMessage {...messages.loadMoreTagsButtonText} />
</Button>
)
: null}
{ !isTaxonomyTagsLoaded ? (
<div className="d-flex justify-content-center align-items-center flex-column">
<Spinner
animation="border"
@@ -78,18 +131,27 @@ const ContentTagsDropDownSelector = ({
screenReaderText={intl.formatMessage(messages.loadingTagsDropdownMessage)}
/>
</div>
)
) : null}
</>
);
};
ContentTagsDropDownSelector.defaultProps = {
subTagsUrl: undefined,
lineage: [],
};
ContentTagsDropDownSelector.propTypes = {
taxonomyId: PropTypes.number.isRequired,
level: PropTypes.number.isRequired,
subTagsUrl: PropTypes.string,
lineage: PropTypes.arrayOf(PropTypes.string),
tagsTree: PropTypes.objectOf(
PropTypes.shape({
explicit: PropTypes.bool.isRequired,
children: PropTypes.shape({}).isRequired,
}).isRequired,
).isRequired,
};
export default ContentTagsDropDownSelector;

View File

@@ -6,3 +6,12 @@
box-shadow: none;
padding: 0;
}
.pgn__selectable_box.taxonomy-tags-selectable-box:disabled,
.pgn__selectable_box.taxonomy-tags-selectable-box[disabled] {
opacity: 1 !important;
}
.pgn__selectable_box-active.taxonomy-tags-selectable-box {
outline: none !important;
}

View File

@@ -1,51 +1,64 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { act, render } from '@testing-library/react';
import { act, render, waitFor } from '@testing-library/react';
import PropTypes from 'prop-types';
import ContentTagsDropDownSelector from './ContentTagsDropDownSelector';
import { useTaxonomyTagsDataResponse, useIsTaxonomyTagsDataLoaded } from './data/apiHooks';
import { useTaxonomyTagsData } from './data/apiHooks';
jest.mock('./data/apiHooks', () => ({
useTaxonomyTagsDataResponse: jest.fn(),
useIsTaxonomyTagsDataLoaded: jest.fn(),
useTaxonomyTagsData: jest.fn(() => ({
isSuccess: false,
data: {},
})),
}));
const data = {
taxonomyId: 123,
level: 0,
tagsTree: {},
};
const TaxonomyTagsDropDownSelectorComponent = ({
taxonomyId, level, subTagsUrl,
const ContentTagsDropDownSelectorComponent = ({
taxonomyId, level, subTagsUrl, lineage, tagsTree,
}) => (
<IntlProvider locale="en" messages={{}}>
<ContentTagsDropDownSelector
taxonomyId={taxonomyId}
level={level}
subTagsUrl={subTagsUrl}
lineage={lineage}
tagsTree={tagsTree}
/>
</IntlProvider>
);
TaxonomyTagsDropDownSelectorComponent.defaultProps = {
ContentTagsDropDownSelectorComponent.defaultProps = {
subTagsUrl: undefined,
lineage: [],
};
TaxonomyTagsDropDownSelectorComponent.propTypes = {
ContentTagsDropDownSelectorComponent.propTypes = {
taxonomyId: PropTypes.number.isRequired,
level: PropTypes.number.isRequired,
subTagsUrl: PropTypes.string,
lineage: PropTypes.arrayOf(PropTypes.string),
tagsTree: PropTypes.objectOf(
PropTypes.shape({
explicit: PropTypes.bool.isRequired,
children: PropTypes.shape({}).isRequired,
}).isRequired,
).isRequired,
};
describe('<ContentTagsDropDownSelector />', () => {
it('should render taxonomy tags drop down selector loading with spinner', async () => {
useIsTaxonomyTagsDataLoaded.mockReturnValue(false);
await act(async () => {
const { getByRole } = render(
<TaxonomyTagsDropDownSelectorComponent
<ContentTagsDropDownSelectorComponent
taxonomyId={data.taxonomyId}
level={data.level}
tagsTree={data.tagsTree}
/>,
);
const spinner = getByRole('status');
@@ -54,43 +67,53 @@ describe('<ContentTagsDropDownSelector />', () => {
});
it('should render taxonomy tags drop down selector with no sub tags', async () => {
useIsTaxonomyTagsDataLoaded.mockReturnValue(true);
useTaxonomyTagsDataResponse.mockReturnValue({
results: [{
value: 'Tag 1',
subTagsUrl: null,
}],
useTaxonomyTagsData.mockReturnValue({
isSuccess: true,
data: {
results: [{
value: 'Tag 1',
subTagsUrl: null,
}],
},
});
await act(async () => {
const { container, getByText } = render(
<TaxonomyTagsDropDownSelectorComponent
<ContentTagsDropDownSelectorComponent
key={`selector-${data.taxonomyId}`}
taxonomyId={data.taxonomyId}
level={data.level}
tagsTree={data.tagsTree}
/>,
);
expect(getByText('Tag 1')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(0);
await waitFor(() => {
expect(getByText('Tag 1')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(0);
});
});
});
it('should render taxonomy tags drop down selector with sub tags', async () => {
useIsTaxonomyTagsDataLoaded.mockReturnValue(true);
useTaxonomyTagsDataResponse.mockReturnValue({
results: [{
value: 'Tag 2',
subTagsUrl: 'https://example.com',
}],
useTaxonomyTagsData.mockReturnValue({
isSuccess: true,
data: {
results: [{
value: 'Tag 2',
subTagsUrl: 'https://example.com',
}],
},
});
await act(async () => {
const { container, getByText } = render(
<TaxonomyTagsDropDownSelectorComponent
<ContentTagsDropDownSelectorComponent
taxonomyId={data.taxonomyId}
level={data.level}
tagsTree={data.tagsTree}
/>,
);
expect(getByText('Tag 2')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
await waitFor(() => {
expect(getByText('Tag 2')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
});
});
});
});

View File

@@ -1,88 +1,77 @@
import React, { useMemo } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import TagBubble from './TagBubble';
/**
* Component that renders Tags under a Taxonomy in the nested tree format
* It constructs a tree structure consolidating the tag data. Example:
* Component that renders Tags under a Taxonomy in the nested tree format.
*
* FROM:
*
* [
* {
* "value": "DNA Sequencing",
* "lineage": [
* "Science and Research",
* "Genetics Subcategory",
* "DNA Sequencing"
* ]
* },
* {
* "value": "Virology",
* "lineage": [
* "Science and Research",
* "Molecular, Cellular, and Microbiology",
* "Virology"
* ]
* }
* ]
*
* TO:
* Example:
*
* {
* "Science and Research": {
* "Genetics Subcategory": {
* "DNA Sequencing": {}
* },
* "Molecular, Cellular, and Microbiology": {
* "Virology": {}
* explicit: false,
* children: {
* "Genetics Subcategory": {
* explicit: false,
* children: {
* "DNA Sequencing": {
* explicit: true,
* children: {}
* }
* }
* },
* "Molecular, Cellular, and Microbiology": {
* explicit: false,
* children: {
* "Virology": {
* explicit: true,
* children: {}
* }
* }
* }
* }
* }
* }
* };
*
* @param {Object[]} appliedContentTags - Array of taxonomy tags that are applied to the content
* @param {string} appliedContentTags.value - Value of applied Tag
* @param {string} appliedContentTags.lineage - Array of Tag's ancestors sorted (ancestor -> tag)
* @param {Object} tagsTree - Array of taxonomy tags that are applied to the content
* @param {Func} removeTagHandler - Function that is called when removing tags from tree
* @param {boolean} editable - Whether the tags appear with an 'x' allowing the user to remove them
*/
const ContentTagsTree = ({ appliedContentTags }) => {
const tagsTree = useMemo(() => {
const tree = {};
appliedContentTags.forEach(tag => {
tag.lineage.reduce((currentLevel, ancestor) => {
// eslint-disable-next-line no-param-reassign
currentLevel[ancestor] = currentLevel[ancestor] || {};
return currentLevel[ancestor];
}, tree);
});
return tree;
}, [appliedContentTags]);
const renderTagsTree = (tag, level) => Object.keys(tag).map((key) => {
const ContentTagsTree = ({ tagsTree, removeTagHandler, editable }) => {
const renderTagsTree = (tag, level, lineage) => Object.keys(tag).map((key) => {
const updatedLineage = [...lineage, encodeURIComponent(key)];
if (tag[key] !== undefined) {
return (
<div key={`tag-${key}-level-${level}`}>
<TagBubble
key={`tag-${key}`}
value={key}
implicit={Object.keys(tag[key]).length !== 0}
implicit={!tag[key].explicit}
level={level}
lineage={updatedLineage}
removeTagHandler={removeTagHandler}
editable={editable}
/>
{ renderTagsTree(tag[key], level + 1) }
{ renderTagsTree(tag[key].children, level + 1, updatedLineage) }
</div>
);
}
return null;
});
return renderTagsTree(tagsTree, 0);
return renderTagsTree(tagsTree, 0, []);
};
ContentTagsTree.propTypes = {
appliedContentTags: PropTypes.arrayOf(PropTypes.shape({
value: PropTypes.string,
lineage: PropTypes.arrayOf(PropTypes.string),
})).isRequired,
tagsTree: PropTypes.objectOf(
PropTypes.shape({
explicit: PropTypes.bool.isRequired,
children: PropTypes.shape({}).isRequired,
}).isRequired,
).isRequired,
removeTagHandler: PropTypes.func.isRequired,
editable: PropTypes.bool.isRequired,
};
export default ContentTagsTree;

View File

@@ -5,42 +5,53 @@ import PropTypes from 'prop-types';
import ContentTagsTree from './ContentTagsTree';
const data = [
{
value: 'DNA Sequencing',
lineage: [
'Science and Research',
'Genetics Subcategory',
'DNA Sequencing',
],
const data = {
'Science and Research': {
explicit: false,
children: {
'Genetics Subcategory': {
explicit: false,
children: {
'DNA Sequencing': {
explicit: true,
children: {},
},
},
},
'Molecular, Cellular, and Microbiology': {
explicit: false,
children: {
Virology: {
explicit: true,
children: {},
},
},
},
},
},
{
value: 'Virology',
lineage: [
'Science and Research',
'Molecular, Cellular, and Microbiology',
'Virology',
],
},
];
};
const ContentTagsTreeComponent = ({ appliedContentTags }) => (
const ContentTagsTreeComponent = ({ tagsTree, removeTagHandler, editable }) => (
<IntlProvider locale="en" messages={{}}>
<ContentTagsTree appliedContentTags={appliedContentTags} />
<ContentTagsTree tagsTree={tagsTree} removeTagHandler={removeTagHandler} editable={editable} />
</IntlProvider>
);
ContentTagsTreeComponent.propTypes = {
appliedContentTags: PropTypes.arrayOf(PropTypes.shape({
value: PropTypes.string,
lineage: PropTypes.arrayOf(PropTypes.string),
})).isRequired,
tagsTree: PropTypes.objectOf(
PropTypes.shape({
explicit: PropTypes.bool.isRequired,
children: PropTypes.shape({}).isRequired,
}).isRequired,
).isRequired,
removeTagHandler: PropTypes.func.isRequired,
editable: PropTypes.bool.isRequired,
};
describe('<ContentTagsTree />', () => {
it('should render taxonomy tags data along content tags number badge', async () => {
await act(async () => {
const { getByText } = render(<ContentTagsTreeComponent appliedContentTags={data} />);
const { getByText } = render(<ContentTagsTreeComponent tagsTree={data} removeTagHandler={() => {}} editable />);
expect(getByText('Science and Research')).toBeInTheDocument();
expect(getByText('Genetics Subcategory')).toBeInTheDocument();
expect(getByText('Molecular, Cellular, and Microbiology')).toBeInTheDocument();

View File

@@ -1,6 +1,6 @@
import React from 'react';
import {
Button,
Chip,
} from '@edx/paragon';
import { Tag, Close } from '@edx/paragon/icons';
import PropTypes from 'prop-types';
@@ -8,35 +8,43 @@ import PropTypes from 'prop-types';
import TagOutlineIcon from './TagOutlineIcon';
const TagBubble = ({
value, subTagsCount, implicit, level,
value, implicit, level, lineage, removeTagHandler, editable,
}) => {
const className = `tag-bubble mb-2 ${implicit ? 'implicit' : ''}`;
const tagIcon = () => (implicit ? <TagOutlineIcon className="implicit-tag-icon" /> : <Tag />);
const className = `tag-bubble mb-2 border-light-300 ${implicit ? 'implicit' : ''}`;
const handleClick = React.useCallback(() => {
if (!implicit && editable) {
removeTagHandler(lineage.join(','), false);
}
}, [implicit, lineage, editable, removeTagHandler]);
return (
<div style={{ paddingLeft: `${level * 1}rem` }}>
<Button
<Chip
className={className}
variant="outline-dark"
iconBefore={tagIcon}
iconAfter={!implicit ? Close : null}
variant="light"
iconBefore={!implicit ? Tag : TagOutlineIcon}
iconAfter={!implicit && editable ? Close : null}
onIconAfterClick={handleClick}
>
{value} {subTagsCount > 0 ? `(${subTagsCount})` : null }
</Button>
{value}
</Chip>
</div>
);
};
TagBubble.defaultProps = {
subTagsCount: 0,
implicit: true,
level: 0,
};
TagBubble.propTypes = {
value: PropTypes.string.isRequired,
subTagsCount: PropTypes.number,
implicit: PropTypes.bool,
level: PropTypes.number,
lineage: PropTypes.arrayOf(PropTypes.string).isRequired,
removeTagHandler: PropTypes.func.isRequired,
editable: PropTypes.bool.isRequired,
};
export default TagBubble;

View File

@@ -1,13 +1,5 @@
.tag-bubble.btn-outline-dark {
border-color: $light-300;
&:hover {
color: $white;
background-color: $dark;
border-color: $dark;
}
}
.implicit > .implicit-tag-icon {
color: $dark;
.tag-bubble.pgn__chip {
border-style: solid;
border-width: 2px;
background-color: transparent;
}

View File

@@ -1,54 +1,61 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { render } from '@testing-library/react';
import { act, render, fireEvent } from '@testing-library/react';
import PropTypes from 'prop-types';
import TagBubble from './TagBubble';
const data = {
value: 'Tag 1',
lineage: [],
removeTagHandler: jest.fn(),
};
const TagBubbleComponent = ({ value, subTagsCount, implicit }) => (
const TagBubbleComponent = ({
value, implicit, level, lineage, removeTagHandler, editable,
}) => (
<IntlProvider locale="en" messages={{}}>
<TagBubble value={value} subTagsCount={subTagsCount} implicit={implicit} />
<TagBubble
value={value}
implicit={implicit}
level={level}
lineage={lineage}
removeTagHandler={removeTagHandler}
editable={editable}
/>
</IntlProvider>
);
TagBubbleComponent.defaultProps = {
subTagsCount: 0,
implicit: true,
level: 0,
};
TagBubbleComponent.propTypes = {
value: PropTypes.string.isRequired,
subTagsCount: PropTypes.number,
implicit: PropTypes.bool,
level: PropTypes.number,
lineage: PropTypes.arrayOf(PropTypes.string).isRequired,
removeTagHandler: PropTypes.func.isRequired,
editable: PropTypes.bool.isRequired,
};
describe('<TagBubble />', () => {
it('should render only value of the implicit tag with no sub tags', () => {
const { container, getByText } = render(<TagBubbleComponent value={data.value} />);
expect(getByText(data.value)).toBeInTheDocument();
expect(container.getElementsByClassName('implicit').length).toBe(1);
});
it('should render value of the implicit tag with sub tags', () => {
const tagBubbleData = {
subTagsCount: 5,
...data,
};
it('should render implicit tag', () => {
const { container, getByText } = render(
<TagBubbleComponent
value={tagBubbleData.value}
subTagsCount={tagBubbleData.subTagsCount}
value={data.value}
editable
lineage={data.lineage}
removeTagHandler={data.removeTagHandler}
/>,
);
expect(getByText(`${tagBubbleData.value} (${tagBubbleData.subTagsCount})`)).toBeInTheDocument();
expect(getByText(data.value)).toBeInTheDocument();
expect(container.getElementsByClassName('implicit').length).toBe(1);
expect(container.getElementsByClassName('pgn__chip__icon-after').length).toBe(0);
});
it('should render value of the explicit tag with no sub tags', () => {
it('should render explicit tag', () => {
const tagBubbleData = {
implicit: false,
...data,
@@ -56,11 +63,36 @@ describe('<TagBubble />', () => {
const { container, getByText } = render(
<TagBubbleComponent
value={tagBubbleData.value}
editable
lineage={data.lineage}
implicit={tagBubbleData.implicit}
removeTagHandler={tagBubbleData.removeTagHandler}
/>,
);
expect(getByText(`${tagBubbleData.value}`)).toBeInTheDocument();
expect(container.getElementsByClassName('implicit').length).toBe(0);
expect(container.getElementsByClassName('btn-icon-after').length).toBe(1);
expect(container.getElementsByClassName('pgn__chip__icon-after').length).toBe(1);
});
it('should call removeTagHandler when "x" clicked on explicit tag', async () => {
const tagBubbleData = {
implicit: false,
...data,
};
const { container } = render(
<TagBubbleComponent
value={tagBubbleData.value}
editable
lineage={data.lineage}
implicit={tagBubbleData.implicit}
removeTagHandler={tagBubbleData.removeTagHandler}
/>,
);
const xButton = container.getElementsByClassName('pgn__chip__icon-after')[0];
await act(async () => {
fireEvent.click(xButton);
});
expect(data.removeTagHandler).toHaveBeenCalled();
});
});

View File

@@ -10,7 +10,6 @@ const TagOutlineIcon = (props) => (
aria-hidden="true"
{...props}
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path
d="m21.41 11.58-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58s1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41s-.23-1.06-.59-1.42zM13 20.01 4 11V4h7v-.01l9 9-7 7.02z"
/>

View File

@@ -1,3 +1,4 @@
export { default as taxonomyTagsMock } from './taxonomyTagsMock';
export { default as contentTaxonomyTagsMock } from './contentTaxonomyTagsMock';
export { default as contentDataMock } from './contentDataMock';
export { default as updateContentTaxonomyTagsMock } from './updateContentTaxonomyTagsMock';

View File

@@ -0,0 +1,25 @@
module.exports = {
'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b': {
taxonomies: [
{
name: 'FlatTaxonomy',
taxonomyId: 3,
editable: true,
tags: [
{
value: 'flat taxonomy tag 100',
lineage: [
'flat taxonomy tag 100',
],
},
{
value: 'flat taxonomy tag 3856',
lineage: [
'flat taxonomy tag 3856',
],
},
],
},
],
},
};

View File

@@ -9,7 +9,7 @@ export const getContentDataApiUrl = (contentId) => new URL(`/xblock/outline/${co
/**
* Get all tags that belong to taxonomy.
* @param {string} taxonomyId The id of the taxonomy to fetch tags for
* @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
* @returns {Promise<Object>}
@@ -40,3 +40,17 @@ export async function getContentData(contentId) {
const { data } = await getAuthenticatedHttpClient().get(getContentDataApiUrl(contentId));
return camelCaseObject(data);
}
/**
* Update content object's applied tags
* @param {string} contentId The id of the content object (unit/component)
* @param {number} taxonomyId The id of the taxonomy the tags belong to
* @param {string[]} tags The list of tags (values) to set on content object
* @returns {Promise<Object>}
*/
export async function updateContentTaxonomyTags(contentId, taxonomyId, tags) {
let url = getContentTaxonomyTagsApiUrl(contentId);
url = `${url}?taxonomy=${taxonomyId}`;
const { data } = await getAuthenticatedHttpClient().put(url, { tags });
return camelCaseObject(data[contentId]);
}

View File

@@ -2,7 +2,12 @@ import MockAdapter from 'axios-mock-adapter';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { taxonomyTagsMock, contentTaxonomyTagsMock, contentDataMock } from '../__mocks__';
import {
taxonomyTagsMock,
contentTaxonomyTagsMock,
contentDataMock,
updateContentTaxonomyTagsMock,
} from '../__mocks__';
import {
getTaxonomyTagsApiUrl,
@@ -11,6 +16,7 @@ import {
getTaxonomyTagsData,
getContentTaxonomyTagsData,
getContentData,
updateContentTaxonomyTags,
} from './api';
let axiosMock;
@@ -33,7 +39,7 @@ describe('content tags drawer api calls', () => {
});
it('should get taxonomy tags data', async () => {
const taxonomyId = '123';
const taxonomyId = 123;
axiosMock.onGet().reply(200, taxonomyTagsMock);
const result = await getTaxonomyTagsData(taxonomyId);
@@ -42,7 +48,7 @@ describe('content tags drawer api calls', () => {
});
it('should get taxonomy tags data with fullPathProvided', async () => {
const taxonomyId = '123';
const taxonomyId = 123;
const fullPathProvided = 'http://example.com/';
axiosMock.onGet().reply(200, taxonomyTagsMock);
const result = await getTaxonomyTagsData(taxonomyId, fullPathProvided);
@@ -68,4 +74,15 @@ describe('content tags drawer api calls', () => {
expect(axiosMock.history.get[0].url).toEqual(getContentDataApiUrl(contentId));
expect(result).toEqual(contentDataMock);
});
it('should update content taxonomy tags', async () => {
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)}?taxonomy=${taxonomyId}`).reply(200, updateContentTaxonomyTagsMock);
const result = await updateContentTaxonomyTags(contentId, taxonomyId, tags);
expect(axiosMock.history.put[0].url).toEqual(`${getContentTaxonomyTagsApiUrl(contentId)}?taxonomy=${taxonomyId}`);
expect(result).toEqual(updateContentTaxonomyTagsMock[contentId]);
});
});

View File

@@ -1,111 +1,71 @@
// @ts-check
import { useQuery } from '@tanstack/react-query';
import { getTaxonomyTagsData, getContentTaxonomyTagsData, getContentData } from './api';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getTaxonomyTagsData,
getContentTaxonomyTagsData,
getContentData,
updateContentTaxonomyTags,
} from './api';
/**
* Builds the query to get the taxonomy tags
* @param {string} taxonomyId The id of the taxonomy to fetch tags for
* @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
* @returns {import("./types.mjs").UseQueryResult}
* @returns {import("@tanstack/react-query").UseQueryResult<import("./types.mjs").TaxonomyTagsData>}
*/
const useTaxonomyTagsData = (taxonomyId, fullPathProvided) => (
export const useTaxonomyTagsData = (taxonomyId, fullPathProvided) => (
useQuery({
queryKey: [`taxonomyTags${ fullPathProvided || taxonomyId }`],
queryFn: () => getTaxonomyTagsData(taxonomyId, fullPathProvided),
})
);
/**
* Gets the taxonomy tags data
* @param {string} 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
* @returns {import("./types.mjs").TaxonomyTagsData | undefined}
*/
export const useTaxonomyTagsDataResponse = (taxonomyId, fullPathProvided) => {
const response = useTaxonomyTagsData(taxonomyId, fullPathProvided);
if (response.status === 'success') {
return response.data;
}
return undefined;
};
/**
* Returns the status of the taxonomy tags query
* @param {string} 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
* @returns {boolean}
*/
export const useIsTaxonomyTagsDataLoaded = (taxonomyId, fullPathProvided) => (
useTaxonomyTagsData(taxonomyId, fullPathProvided).status === 'success'
);
/**
* Builds the query to get the taxonomy tags applied to the content object
* @param {string} contentId The id of the content object to fetch the applied tags for
* @returns {import("./types.mjs").UseQueryResult}
* @returns {import("@tanstack/react-query").UseQueryResult<import("./types.mjs").ContentTaxonomyTagsData>}
*/
const useContentTaxonomyTagsData = (contentId) => (
export const useContentTaxonomyTagsData = (contentId) => (
useQuery({
queryKey: ['contentTaxonomyTags'],
queryKey: ['contentTaxonomyTags', contentId],
queryFn: () => getContentTaxonomyTagsData(contentId),
})
);
/**
* Gets the taxonomy tags applied to the content object
* @param {string} contentId The id of the content object to fetch the applied tags for
* @returns {import("./types.mjs").ContentTaxonomyTagsData | undefined}
*/
export const useContentTaxonomyTagsDataResponse = (contentId) => {
const response = useContentTaxonomyTagsData(contentId);
if (response.status === 'success') {
return response.data;
}
return undefined;
};
/**
* Gets the status of the content taxonomy tags query
* @param {string} contentId The id of the content object to fetch the applied tags for
* @returns {boolean}
*/
export const useIsContentTaxonomyTagsDataLoaded = (contentId) => (
useContentTaxonomyTagsData(contentId).status === 'success'
);
/**
* Builds the query to get meta data about the content object
* @param {string} contentId The id of the content object (unit/component)
* @returns {import("./types.mjs").UseQueryResult}
* @returns {import("@tanstack/react-query").UseQueryResult<import("./types.mjs").ContentData>}
*/
const useContentData = (contentId) => (
export const useContentData = (contentId) => (
useQuery({
queryKey: ['contentData'],
queryKey: ['contentData', contentId],
queryFn: () => getContentData(contentId),
})
);
/**
* Gets the information about the content object
* @param {string} contentId The id of the content object (unit/component)
* @returns {import("./types.mjs").ContentData | undefined}
* Builds the mutation to update the tags applied to the content object
* @param {string} contentId The id of the content object to update tags for
* @param {number} taxonomyId The id of the taxonomy the tags belong to
*/
export const useContentDataResponse = (contentId) => {
const response = useContentData(contentId);
if (response.status === 'success') {
return response.data;
}
return undefined;
};
export const useContentTaxonomyTagsUpdater = (contentId, taxonomyId) => {
const queryClient = useQueryClient();
/**
* Gets the status of the content data query
* @param {string} contentId The id of the content object (unit/component)
* @returns {boolean}
*/
export const useIsContentDataLoaded = (contentId) => (
useContentData(contentId).status === 'success'
);
return useMutation({
/**
* @type {import("@tanstack/react-query").MutateFunction<
* any,
* any,
* {
* tags: string[]
* }
* >}
*/
mutationFn: ({ tags }) => updateContentTaxonomyTags(contentId, taxonomyId, tags),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTags', contentId] });
},
});
};

View File

@@ -1,121 +1,96 @@
import { useQuery } from '@tanstack/react-query';
import { useQuery, useMutation } from '@tanstack/react-query';
import { act } from '@testing-library/react';
import {
useTaxonomyTagsDataResponse,
useIsTaxonomyTagsDataLoaded,
useContentTaxonomyTagsDataResponse,
useIsContentTaxonomyTagsDataLoaded,
useContentDataResponse,
useIsContentDataLoaded,
useTaxonomyTagsData,
useContentTaxonomyTagsData,
useContentData,
useContentTaxonomyTagsUpdater,
} from './apiHooks';
import { updateContentTaxonomyTags } from './api';
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn(),
useMutation: jest.fn(),
useQueryClient: jest.fn(),
}));
describe('useTaxonomyTagsDataResponse', () => {
it('should return data when status is success', () => {
useQuery.mockReturnValueOnce({ status: 'success', data: { data: 'data' } });
const taxonomyId = '123';
const result = useTaxonomyTagsDataResponse(taxonomyId);
jest.mock('./api', () => ({
updateContentTaxonomyTags: jest.fn(),
}));
expect(result).toEqual({ data: 'data' });
describe('useTaxonomyTagsData', () => {
it('should return success response', () => {
useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
const taxonomyId = 123;
const result = useTaxonomyTagsData(taxonomyId);
expect(result).toEqual({ isSuccess: true, data: 'data' });
});
it('should return undefined when status is not success', () => {
useQuery.mockReturnValueOnce({ status: 'error' });
const taxonomyId = '123';
const result = useTaxonomyTagsDataResponse(taxonomyId);
it('should return failure response', () => {
useQuery.mockReturnValueOnce({ isSuccess: false });
const taxonomyId = 123;
const result = useTaxonomyTagsData(taxonomyId);
expect(result).toBeUndefined();
expect(result).toEqual({ isSuccess: false });
});
});
describe('useIsTaxonomyTagsDataLoaded', () => {
it('should return true when status is success', () => {
useQuery.mockReturnValueOnce({ status: 'success' });
const taxonomyId = '123';
const result = useIsTaxonomyTagsDataLoaded(taxonomyId);
describe('useContentTaxonomyTagsData', () => {
it('should return success response', () => {
useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
const contentId = '123';
const result = useContentTaxonomyTagsData(contentId);
expect(result).toBe(true);
expect(result).toEqual({ isSuccess: true, data: 'data' });
});
it('should return false when status is not success', () => {
useQuery.mockReturnValueOnce({ status: 'error' });
const taxonomyId = '123';
const result = useIsTaxonomyTagsDataLoaded(taxonomyId);
it('should return failure response', () => {
useQuery.mockReturnValueOnce({ isSuccess: false });
const contentId = '123';
const result = useContentTaxonomyTagsData(contentId);
expect(result).toBe(false);
expect(result).toEqual({ isSuccess: false });
});
});
describe('useContentTaxonomyTagsDataResponse', () => {
it('should return data when status is success', () => {
useQuery.mockReturnValueOnce({ status: 'success', data: { data: 'data' } });
describe('useContentData', () => {
it('should return success response', () => {
useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
const contentId = '123';
const result = useContentTaxonomyTagsDataResponse(contentId);
const result = useContentData(contentId);
expect(result).toEqual({ data: 'data' });
expect(result).toEqual({ isSuccess: true, data: 'data' });
});
it('should return undefined when status is not success', () => {
useQuery.mockReturnValueOnce({ status: 'error' });
it('should return failure response', () => {
useQuery.mockReturnValueOnce({ isSuccess: false });
const contentId = '123';
const result = useContentTaxonomyTagsDataResponse(contentId);
const result = useContentData(contentId);
expect(result).toBeUndefined();
expect(result).toEqual({ isSuccess: false });
});
});
describe('useIsContentTaxonomyTagsDataLoaded', () => {
it('should return true when status is success', () => {
useQuery.mockReturnValueOnce({ status: 'success' });
const contentId = '123';
const result = useIsContentTaxonomyTagsDataLoaded(contentId);
describe('useContentTaxonomyTagsUpdater', () => {
it('should call the update content taxonomy tags function', async () => {
useMutation.mockReturnValueOnce({ mutate: jest.fn() });
expect(result).toBe(true);
});
const contentId = 'testerContent';
const taxonomyId = 123;
const mutation = useContentTaxonomyTagsUpdater(contentId, taxonomyId);
mutation.mutate({ tags: ['tag1', 'tag2'] });
it('should return false when status is not success', () => {
useQuery.mockReturnValueOnce({ status: 'error' });
const contentId = '123';
const result = useIsContentTaxonomyTagsDataLoaded(contentId);
expect(useMutation).toBeCalled();
expect(result).toBe(false);
});
});
describe('useContentDataResponse', () => {
it('should return data when status is success', () => {
useQuery.mockReturnValueOnce({ status: 'success', data: { data: 'data' } });
const contentId = '123';
const result = useContentDataResponse(contentId);
expect(result).toEqual({ data: 'data' });
});
it('should return undefined when status is not success', () => {
useQuery.mockReturnValueOnce({ status: 'error' });
const contentId = '123';
const result = useContentDataResponse(contentId);
expect(result).toBeUndefined();
});
});
describe('useIsContentDataLoaded', () => {
it('should return true when status is success', () => {
useQuery.mockReturnValueOnce({ status: 'success' });
const contentId = '123';
const result = useIsContentDataLoaded(contentId);
expect(result).toBe(true);
});
it('should return false when status is not success', () => {
useQuery.mockReturnValueOnce({ status: 'error' });
const contentId = '123';
const result = useIsContentDataLoaded(contentId);
expect(result).toBe(false);
const [config] = useMutation.mock.calls[0];
const { mutationFn } = config;
await act(async () => {
const tags = ['tag1', 'tag2'];
await mutationFn({ tags });
expect(updateContentTaxonomyTags).toBeCalledWith(contentId, taxonomyId, tags);
});
});
});

View File

@@ -17,6 +17,10 @@ const messages = defineMessages({
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.spinner.loading',
defaultMessage: 'Loading tags',
},
loadMoreTagsButtonText: {
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.load-more-tags.button',
defaultMessage: 'Load more',
},
taxonomyTagsAriaLabel: {
id: 'course-authoring.content-tags-drawer.content-tags-collapsible.selectable-box.selection.aria.label',
defaultMessage: 'taxonomy tags selection',