feat: Search Content Tags (#737)
This change adds the ability to search content tags in the content tags drawer, in order to filter tags. This change also refactors the way data is loaded from the server, handling pre-loaded data and pagination.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
// @ts-check
|
||||
import React from 'react';
|
||||
import {
|
||||
Badge,
|
||||
@@ -6,10 +7,12 @@ import {
|
||||
Button,
|
||||
ModalPopup,
|
||||
useToggle,
|
||||
SearchField,
|
||||
} from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { debounce } from 'lodash';
|
||||
import messages from './messages';
|
||||
import './ContentTagsCollapsible.scss';
|
||||
|
||||
@@ -19,6 +22,9 @@ import ContentTagsTree from './ContentTagsTree';
|
||||
|
||||
import useContentTagsCollapsibleHelper from './ContentTagsCollapsibleHelper';
|
||||
|
||||
/** @typedef {import("../taxonomy/data/types.mjs").TaxonomyData} TaxonomyData */
|
||||
/** @typedef {import("./data/types.mjs").Tag} ContentTagData */
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -89,22 +95,11 @@ import useContentTagsCollapsibleHelper from './ContentTagsCollapsibleHelper';
|
||||
* 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
|
||||
* @param {string} taxonomyAndTagsData.description - description of Taxonomy
|
||||
* @param {boolean} taxonomyAndTagsData.enabled - Whether Taxonomy is enabled/disabled
|
||||
* @param {boolean} taxonomyAndTagsData.allowMultiple - Whether Taxonomy allows multiple tags to be applied
|
||||
* @param {boolean} taxonomyAndTagsData.allowFreeText - Whether Taxonomy allows free text tags
|
||||
* @param {boolean} taxonomyAndTagsData.systemDefined - Whether Taxonomy is system defined or authored by user
|
||||
* @param {boolean} taxonomyAndTagsData.visibleToAuthors - Whether Taxonomy should be visible to object authors
|
||||
* @param {string[]} taxonomyAndTagsData.orgs - Array of orgs this Taxonomy belongs to
|
||||
* @param {boolean} taxonomyAndTagsData.allOrgs - Whether Taxonomy belongs to all orgs
|
||||
* @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
|
||||
*
|
||||
* @param {Object} props - The component props.
|
||||
* @param {string} props.contentId - Id of the content object
|
||||
* @param {TaxonomyData & {contentTags: ContentTagData[]}} props.taxonomyAndTagsData - Taxonomy metadata & applied tags
|
||||
* @param {boolean} props.editable - Whether the tags can be edited
|
||||
*/
|
||||
const ContentTagsCollapsible = ({ contentId, taxonomyAndTagsData, editable }) => {
|
||||
const intl = useIntl();
|
||||
@@ -117,9 +112,30 @@ const ContentTagsCollapsible = ({ contentId, taxonomyAndTagsData, editable }) =>
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const [addTagsButtonRef, setAddTagsButtonRef] = React.useState(null);
|
||||
|
||||
const [searchTerm, setSearchTerm] = React.useState('');
|
||||
|
||||
const handleSelectableBoxChange = React.useCallback((e) => {
|
||||
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('');
|
||||
} else {
|
||||
handleSearch(value);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const modalPopupOnCloseHandler = React.useCallback((event) => {
|
||||
close(event);
|
||||
// Clear search term
|
||||
setSearchTerm('');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="d-flex">
|
||||
@@ -145,7 +161,7 @@ const ContentTagsCollapsible = ({ contentId, taxonomyAndTagsData, editable }) =>
|
||||
placement="bottom"
|
||||
positionRef={addTagsButtonRef}
|
||||
isOpen={isOpen}
|
||||
onClose={close}
|
||||
onClose={modalPopupOnCloseHandler}
|
||||
>
|
||||
<div className="bg-white p-3 shadow">
|
||||
|
||||
@@ -158,11 +174,18 @@ const ContentTagsCollapsible = ({ contentId, taxonomyAndTagsData, editable }) =>
|
||||
onChange={handleSelectableBoxChange}
|
||||
value={checkedTags}
|
||||
>
|
||||
<SearchField
|
||||
onSubmit={() => {}}
|
||||
onChange={handleSearchChange}
|
||||
className="mb-2"
|
||||
/>
|
||||
|
||||
<ContentTagsDropDownSelector
|
||||
key={`selector-${id}`}
|
||||
taxonomyId={id}
|
||||
level={0}
|
||||
tagsTree={tagsTree}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
</SelectableBox.Set>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
.taxonomy-tags-selectable-box-set {
|
||||
grid-auto-rows: unset !important;
|
||||
grid-gap: unset !important;
|
||||
overflow-y: scroll;
|
||||
max-height: 20rem;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
act,
|
||||
render,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import ContentTagsCollapsible from './ContentTagsCollapsible';
|
||||
@@ -18,8 +18,12 @@ jest.mock('./data/apiHooks', () => ({
|
||||
mutate: jest.fn(),
|
||||
})),
|
||||
useTaxonomyTagsData: jest.fn(() => ({
|
||||
isSuccess: false,
|
||||
data: {},
|
||||
hasMorePages: false,
|
||||
tagPages: [{
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
data: [],
|
||||
}],
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -66,153 +70,195 @@ ContentTagsCollapsibleComponent.propTypes = {
|
||||
};
|
||||
|
||||
describe('<ContentTagsCollapsible />', () => {
|
||||
it('should render taxonomy tags data along content tags number badge', async () => {
|
||||
await act(async () => {
|
||||
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('3')).toBeInTheDocument();
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers(); // To account for debounce timer
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers(); // Restore real timers after the tests
|
||||
});
|
||||
|
||||
async function getComponent(updatedData) {
|
||||
const componentData = (!updatedData ? data : updatedData);
|
||||
|
||||
return render(
|
||||
<ContentTagsCollapsibleComponent
|
||||
contentId={componentData.contentId}
|
||||
taxonomyAndTagsData={componentData.taxonomyAndTagsData}
|
||||
editable={componentData.editable}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function setupTaxonomyMock() {
|
||||
useTaxonomyTagsData.mockReturnValue({
|
||||
hasMorePages: false,
|
||||
tagPages: [{
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 1',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12345,
|
||||
subTagsUrl: null,
|
||||
}, {
|
||||
value: 'Tag 2',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12346,
|
||||
subTagsUrl: null,
|
||||
}, {
|
||||
value: 'Tag 3',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12347,
|
||||
subTagsUrl: null,
|
||||
}],
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
it('should render taxonomy tags data along content tags number badge', async () => {
|
||||
const { container, getByText } = await getComponent();
|
||||
expect(getByText('Taxonomy 1')).toBeInTheDocument();
|
||||
expect(container.getElementsByClassName('badge').length).toBe(1);
|
||||
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,
|
||||
}],
|
||||
},
|
||||
});
|
||||
setupTaxonomyMock();
|
||||
const { container, getByText, getAllByText } = await getComponent();
|
||||
|
||||
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];
|
||||
fireEvent.click(expandToggle);
|
||||
|
||||
// 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);
|
||||
fireEvent.click(addTagsButton);
|
||||
|
||||
// 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
|
||||
expect(getByText('Tag 3')).toBeInTheDocument();
|
||||
expect(getAllByText('Tag 3').length === 1);
|
||||
|
||||
// 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');
|
||||
|
||||
const tag3 = getByText('Tag 3');
|
||||
await act(async () => {
|
||||
fireEvent.click(tag3);
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
// After clicking on Tag 3, it should also appear in amongst
|
||||
// the tag bubbles in the tree
|
||||
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,
|
||||
}],
|
||||
},
|
||||
});
|
||||
setupTaxonomyMock();
|
||||
const { container, getByText, getAllByText } = await getComponent();
|
||||
|
||||
// Expand the Taxonomy to view applied tags and "Add tags" button
|
||||
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||
|
||||
fireEvent.click(expandToggle);
|
||||
|
||||
// Check that Tag 2 appears in tag bubbles
|
||||
expect(getByText('Tag 2')).toBeInTheDocument();
|
||||
|
||||
// Click on "Add tags" button to open dropdown to select new tags
|
||||
const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage);
|
||||
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)
|
||||
expect(getByText('Tag 3')).toBeInTheDocument();
|
||||
|
||||
// Get the Tag 2 checkbox and click on it
|
||||
const tag2 = getAllByText('Tag 2')[1];
|
||||
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 handle search term change', async () => {
|
||||
const {
|
||||
container, getByText, getByRole, getByDisplayValue,
|
||||
} = await getComponent();
|
||||
|
||||
// Expand the Taxonomy to view applied tags and "Add tags" button
|
||||
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||
fireEvent.click(expandToggle);
|
||||
|
||||
// Click on "Add tags" button to open dropdown
|
||||
const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage);
|
||||
fireEvent.click(addTagsButton);
|
||||
|
||||
// Get the search field
|
||||
const searchField = getByRole('searchbox');
|
||||
|
||||
const searchTerm = 'memo';
|
||||
|
||||
// Trigger a change in the search field
|
||||
userEvent.type(searchField, searchTerm);
|
||||
|
||||
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);
|
||||
// Fast-forward time by 500 milliseconds (for the debounce delay)
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Check that the search term has been set
|
||||
expect(searchField).toHaveValue(searchTerm);
|
||||
expect(getByDisplayValue(searchTerm)).toBeInTheDocument();
|
||||
|
||||
// Clear search
|
||||
userEvent.clear(searchField);
|
||||
|
||||
// Check that the search term has been cleared
|
||||
expect(searchField).toHaveValue('');
|
||||
});
|
||||
|
||||
it('should close dropdown selector when clicking away', async () => {
|
||||
setupTaxonomyMock();
|
||||
const { container, getByText, queryByText } = await getComponent();
|
||||
|
||||
// Expand the Taxonomy to view applied tags and "Add tags" button
|
||||
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
|
||||
|
||||
fireEvent.click(expandToggle);
|
||||
|
||||
// Click on "Add tags" button to open dropdown
|
||||
const addTagsButton = getByText(messages.addTagsButtonText.defaultMessage);
|
||||
fireEvent.click(addTagsButton);
|
||||
|
||||
// Wait for the dropdown selector for tags to open, Tag 3 should appear
|
||||
// since it is not applied
|
||||
expect(queryByText('Tag 3')).toBeInTheDocument();
|
||||
|
||||
// Simulate clicking outside the dropdown remove focus
|
||||
userEvent.click(document.body);
|
||||
|
||||
// Simulate clicking outside the dropdown again to close it
|
||||
userEvent.click(document.body);
|
||||
|
||||
// Wait for the dropdown selector for tags to close, Tag 3 is no longer on
|
||||
// the page
|
||||
expect(queryByText('Tag 3')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render taxonomy tags data without tags number badge', async () => {
|
||||
const updatedData = { ...data };
|
||||
updatedData.taxonomyAndTagsData = { ...updatedData.taxonomyAndTagsData };
|
||||
updatedData.taxonomyAndTagsData.contentTags = [];
|
||||
await act(async () => {
|
||||
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);
|
||||
});
|
||||
const { container, getByText } = await getComponent(updatedData);
|
||||
|
||||
expect(getByText('Taxonomy 1')).toBeInTheDocument();
|
||||
expect(container.getElementsByClassName('invisible').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-check
|
||||
import React from 'react';
|
||||
import { useCheckboxSetValues } from '@edx/paragon';
|
||||
import { cloneDeep } from 'lodash';
|
||||
@@ -196,7 +197,7 @@ const useContentTagsCollapsibleHelper = (contentId, taxonomyAndTagsData) => {
|
||||
|
||||
setAddedContentTags(addedTree);
|
||||
setUpdatingTags(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
tagChangeHandler, tagsTree, contentTagsCount, checkedTags,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-check
|
||||
import React, { useMemo, useEffect } from 'react';
|
||||
import {
|
||||
Container,
|
||||
@@ -16,9 +17,12 @@ import {
|
||||
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks';
|
||||
import Loading from '../generic/Loading';
|
||||
|
||||
/** @typedef {import("../taxonomy/data/types.mjs").TaxonomyData} TaxonomyData */
|
||||
/** @typedef {import("./data/types.mjs").Tag} ContentTagData */
|
||||
|
||||
const ContentTagsDrawer = () => {
|
||||
const intl = useIntl();
|
||||
const { contentId } = useParams();
|
||||
const { contentId } = /** @type {{contentId: string}} */(useParams());
|
||||
|
||||
const org = extractOrgFromContentId(contentId);
|
||||
|
||||
@@ -58,11 +62,10 @@ const ContentTagsDrawer = () => {
|
||||
const taxonomies = useMemo(() => {
|
||||
if (taxonomyListData && contentTaxonomyTagsData) {
|
||||
// Initialize list of content tags in taxonomies to populate
|
||||
const taxonomiesList = taxonomyListData.results.map((taxonomy) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
taxonomy.contentTags = [];
|
||||
return taxonomy;
|
||||
});
|
||||
const taxonomiesList = taxonomyListData.results.map((taxonomy) => ({
|
||||
...taxonomy,
|
||||
contentTags: /** @type {ContentTagData[]} */([]),
|
||||
}));
|
||||
|
||||
const contentTaxonomies = contentTaxonomyTagsData.taxonomies;
|
||||
|
||||
|
||||
@@ -140,9 +140,7 @@ describe('<ContentTagsDrawer />', () => {
|
||||
|
||||
// Find the CloseButton element by its test ID and trigger a click event
|
||||
const closeButton = getByTestId('drawer-close-button');
|
||||
await act(async () => {
|
||||
fireEvent.click(closeButton);
|
||||
});
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
expect(postMessageSpy).toHaveBeenCalledWith('closeManageTagsDrawer', '*');
|
||||
|
||||
@@ -154,10 +152,8 @@ describe('<ContentTagsDrawer />', () => {
|
||||
|
||||
const { container } = render(<RootWrapper />);
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(container, {
|
||||
key: 'Escape',
|
||||
});
|
||||
fireEvent.keyDown(container, {
|
||||
key: 'Escape',
|
||||
});
|
||||
|
||||
expect(postMessageSpy).toHaveBeenCalledWith('closeManageTagsDrawer', '*');
|
||||
@@ -175,10 +171,8 @@ describe('<ContentTagsDrawer />', () => {
|
||||
selectableBox.setAttribute('data-selectable-box', 'taxonomy-tags');
|
||||
document.body.appendChild(selectableBox);
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(container, {
|
||||
key: 'Escape',
|
||||
});
|
||||
fireEvent.keyDown(container, {
|
||||
key: 'Escape',
|
||||
});
|
||||
|
||||
expect(postMessageSpy).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
// @ts-check
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
SelectableBox,
|
||||
Icon,
|
||||
@@ -14,137 +15,144 @@ import './ContentTagsDropDownSelector.scss';
|
||||
import { useTaxonomyTagsData } from './data/apiHooks';
|
||||
|
||||
const ContentTagsDropDownSelector = ({
|
||||
taxonomyId, level, subTagsUrl, lineage, tagsTree,
|
||||
taxonomyId, level, 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 = (tagValue) => dropdownStates[tagValue];
|
||||
|
||||
const [tags, setTags] = useState([]);
|
||||
const [nextPage, setNextPage] = useState(null);
|
||||
const [numPages, setNumPages] = useState(1);
|
||||
const parentTagValue = lineage.length ? decodeURIComponent(lineage[lineage.length - 1]) : null;
|
||||
const { hasMorePages, tagPages } = useTaxonomyTagsData(taxonomyId, parentTagValue, numPages, searchTerm);
|
||||
|
||||
// `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 [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);
|
||||
setNumPages(1);
|
||||
}
|
||||
|
||||
const clickAndEnterHandler = (i) => {
|
||||
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.
|
||||
setDropdownStates({ ...dropdownStates, [i]: !dropdownStates[i] });
|
||||
setDropdownStates({ ...dropdownStates, [tagValue]: !dropdownStates[tagValue] });
|
||||
};
|
||||
|
||||
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 || {};
|
||||
traversal = traversal[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]);
|
||||
setNumPages((x) => x + 1);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{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
|
||||
inputHidden={false}
|
||||
type="checkbox"
|
||||
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>
|
||||
{ taxonomyTag.subTagsUrl
|
||||
&& (
|
||||
<div className="d-flex align-items-center taxonomy-tags-arrow-drop-down" data-link={taxonomyTag.subTagsUrl}>
|
||||
<Icon
|
||||
src={isOpen(i) ? ArrowDropUp : ArrowDropDown}
|
||||
onClick={() => clickAndEnterHandler(i)}
|
||||
tabIndex="0"
|
||||
onKeyPress={(event) => (event.key === 'Enter' ? clickAndEnterHandler(i) : null)}
|
||||
/>
|
||||
<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 */}
|
||||
|
||||
{tagPage.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)}
|
||||
>
|
||||
{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>
|
||||
|
||||
</div>
|
||||
|
||||
{ tagData.childCount > 0 && isOpen(tagData.value) && (
|
||||
<ContentTagsDropDownSelector
|
||||
taxonomyId={taxonomyId}
|
||||
level={level + 1}
|
||||
lineage={[...lineage, tagData.value]}
|
||||
tagsTree={tagsTree}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ taxonomyTag.subTagsUrl && isOpen(i) && (
|
||||
<ContentTagsDropDownSelector
|
||||
key={`selector-${taxonomyTag.value}`}
|
||||
taxonomyId={taxonomyId}
|
||||
subTagsUrl={taxonomyTag.subTagsUrl}
|
||||
level={level + 1}
|
||||
lineage={[...lineage, encodeURIComponent(taxonomyTag.value)]}
|
||||
tagsTree={tagsTree}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{ nextPage && isTaxonomyTagsLoaded
|
||||
{ hasMorePages
|
||||
? (
|
||||
<Button
|
||||
style={{ marginLeft: `${level * 1}rem` }}
|
||||
variant="outline-primary"
|
||||
onClick={loadMoreTags}
|
||||
>
|
||||
<FormattedMessage {...messages.loadMoreTagsButtonText} />
|
||||
</Button>
|
||||
<div className="d-flex justify-content-center align-items-center flex-row">
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
onClick={loadMoreTags}
|
||||
className="mb-2 taxonomy-tags-load-more-button"
|
||||
>
|
||||
<FormattedMessage {...messages.loadMoreTagsButtonText} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
|
||||
{ !isTaxonomyTagsLoaded ? (
|
||||
<div className="d-flex justify-content-center align-items-center flex-column">
|
||||
<Spinner
|
||||
animation="border"
|
||||
size="xl"
|
||||
screenReaderText={intl.formatMessage(messages.loadingTagsDropdownMessage)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ContentTagsDropDownSelector.defaultProps = {
|
||||
subTagsUrl: undefined,
|
||||
lineage: [],
|
||||
searchTerm: '',
|
||||
};
|
||||
|
||||
ContentTagsDropDownSelector.propTypes = {
|
||||
taxonomyId: PropTypes.number.isRequired,
|
||||
level: PropTypes.number.isRequired,
|
||||
subTagsUrl: PropTypes.string,
|
||||
lineage: PropTypes.arrayOf(PropTypes.string),
|
||||
tagsTree: PropTypes.objectOf(
|
||||
PropTypes.shape({
|
||||
@@ -152,6 +160,7 @@ ContentTagsDropDownSelector.propTypes = {
|
||||
children: PropTypes.shape({}).isRequired,
|
||||
}).isRequired,
|
||||
).isRequired,
|
||||
searchTerm: PropTypes.string,
|
||||
};
|
||||
|
||||
export default ContentTagsDropDownSelector;
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.taxonomy-tags-load-more-button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pgn__selectable_box.taxonomy-tags-selectable-box {
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import React from 'react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
act,
|
||||
render,
|
||||
waitFor,
|
||||
fireEvent,
|
||||
} from '@testing-library/react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import ContentTagsDropDownSelector from './ContentTagsDropDownSelector';
|
||||
@@ -8,8 +13,12 @@ import { useTaxonomyTagsData } from './data/apiHooks';
|
||||
|
||||
jest.mock('./data/apiHooks', () => ({
|
||||
useTaxonomyTagsData: jest.fn(() => ({
|
||||
isSuccess: false,
|
||||
data: {},
|
||||
hasMorePages: false,
|
||||
tagPages: [{
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
data: [],
|
||||
}],
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -20,28 +29,27 @@ const data = {
|
||||
};
|
||||
|
||||
const ContentTagsDropDownSelectorComponent = ({
|
||||
taxonomyId, level, subTagsUrl, lineage, tagsTree,
|
||||
taxonomyId, level, lineage, tagsTree, searchTerm,
|
||||
}) => (
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<ContentTagsDropDownSelector
|
||||
taxonomyId={taxonomyId}
|
||||
level={level}
|
||||
subTagsUrl={subTagsUrl}
|
||||
lineage={lineage}
|
||||
tagsTree={tagsTree}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
ContentTagsDropDownSelectorComponent.defaultProps = {
|
||||
subTagsUrl: undefined,
|
||||
lineage: [],
|
||||
searchTerm: '',
|
||||
};
|
||||
|
||||
ContentTagsDropDownSelectorComponent.propTypes = {
|
||||
taxonomyId: PropTypes.number.isRequired,
|
||||
level: PropTypes.number.isRequired,
|
||||
subTagsUrl: PropTypes.string,
|
||||
lineage: PropTypes.arrayOf(PropTypes.string),
|
||||
tagsTree: PropTypes.objectOf(
|
||||
PropTypes.shape({
|
||||
@@ -49,9 +57,14 @@ ContentTagsDropDownSelectorComponent.propTypes = {
|
||||
children: PropTypes.shape({}).isRequired,
|
||||
}).isRequired,
|
||||
).isRequired,
|
||||
searchTerm: PropTypes.string,
|
||||
};
|
||||
|
||||
describe('<ContentTagsDropDownSelector />', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render taxonomy tags drop down selector loading with spinner', async () => {
|
||||
await act(async () => {
|
||||
const { getByRole } = render(
|
||||
@@ -68,14 +81,22 @@ describe('<ContentTagsDropDownSelector />', () => {
|
||||
|
||||
it('should render taxonomy tags drop down selector with no sub tags', async () => {
|
||||
useTaxonomyTagsData.mockReturnValue({
|
||||
isSuccess: true,
|
||||
data: {
|
||||
results: [{
|
||||
hasMorePages: false,
|
||||
tagPages: [{
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 1',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12345,
|
||||
subTagsUrl: null,
|
||||
}],
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const { container, getByText } = render(
|
||||
<ContentTagsDropDownSelectorComponent
|
||||
@@ -94,14 +115,22 @@ describe('<ContentTagsDropDownSelector />', () => {
|
||||
|
||||
it('should render taxonomy tags drop down selector with sub tags', async () => {
|
||||
useTaxonomyTagsData.mockReturnValue({
|
||||
isSuccess: true,
|
||||
data: {
|
||||
results: [{
|
||||
hasMorePages: false,
|
||||
tagPages: [{
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 2',
|
||||
subTagsUrl: 'https://example.com',
|
||||
externalId: null,
|
||||
childCount: 1,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12345,
|
||||
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=Tag%202',
|
||||
}],
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const { container, getByText } = render(
|
||||
<ContentTagsDropDownSelectorComponent
|
||||
@@ -116,4 +145,189 @@ describe('<ContentTagsDropDownSelector />', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should expand on click taxonomy tags drop down selector with sub tags', async () => {
|
||||
useTaxonomyTagsData.mockReturnValueOnce({
|
||||
hasMorePages: false,
|
||||
tagPages: [{
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 2',
|
||||
externalId: null,
|
||||
childCount: 1,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12345,
|
||||
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=Tag%202',
|
||||
}],
|
||||
}],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const dataWithTagsTree = {
|
||||
...data,
|
||||
tagsTree: {
|
||||
'Tag 3': {
|
||||
explicit: false,
|
||||
children: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { container, getByText } = render(
|
||||
<ContentTagsDropDownSelectorComponent
|
||||
taxonomyId={dataWithTagsTree.taxonomyId}
|
||||
level={dataWithTagsTree.level}
|
||||
tagsTree={dataWithTagsTree.tagsTree}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(getByText('Tag 2')).toBeInTheDocument();
|
||||
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
|
||||
});
|
||||
|
||||
// Mock useTaxonomyTagsData again since it gets called in the recursive call
|
||||
useTaxonomyTagsData.mockReturnValueOnce({
|
||||
hasMorePages: false,
|
||||
tagPages: [{
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 3',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 1,
|
||||
parentValue: 'Tag 2',
|
||||
id: 12346,
|
||||
subTagsUrl: null,
|
||||
}],
|
||||
}],
|
||||
});
|
||||
|
||||
// Expand the dropdown to see the subtags selectors
|
||||
const expandToggle = container.querySelector('.taxonomy-tags-arrow-drop-down span');
|
||||
fireEvent.click(expandToggle);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Tag 3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should expand on enter key taxonomy tags drop down selector with sub tags', async () => {
|
||||
useTaxonomyTagsData.mockReturnValueOnce({
|
||||
hasMorePages: false,
|
||||
tagPages: [{
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 2',
|
||||
externalId: null,
|
||||
childCount: 1,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12345,
|
||||
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=Tag%202',
|
||||
}],
|
||||
}],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const dataWithTagsTree = {
|
||||
...data,
|
||||
tagsTree: {
|
||||
'Tag 3': {
|
||||
explicit: false,
|
||||
children: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { container, getByText } = render(
|
||||
<ContentTagsDropDownSelectorComponent
|
||||
taxonomyId={dataWithTagsTree.taxonomyId}
|
||||
level={dataWithTagsTree.level}
|
||||
tagsTree={dataWithTagsTree.tagsTree}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(getByText('Tag 2')).toBeInTheDocument();
|
||||
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
|
||||
});
|
||||
|
||||
// Mock useTaxonomyTagsData again since it gets called in the recursive call
|
||||
useTaxonomyTagsData.mockReturnValueOnce({
|
||||
hasMorePages: false,
|
||||
tagPages: [{
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 3',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 1,
|
||||
parentValue: 'Tag 2',
|
||||
id: 12346,
|
||||
subTagsUrl: null,
|
||||
}],
|
||||
}],
|
||||
});
|
||||
|
||||
// Expand the dropdown to see the subtags selectors
|
||||
const expandToggle = container.querySelector('.taxonomy-tags-arrow-drop-down span');
|
||||
fireEvent.keyPress(expandToggle, { key: 'Enter', charCode: 13 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Tag 3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should render taxonomy tags drop down selector and change search term', async () => {
|
||||
useTaxonomyTagsData.mockReturnValueOnce({
|
||||
hasMorePages: false,
|
||||
tagPages: [{
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [{
|
||||
value: 'Tag 1',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 12345,
|
||||
subTagsUrl: null,
|
||||
}],
|
||||
}],
|
||||
});
|
||||
|
||||
const initalSearchTerm = 'test 1';
|
||||
await act(async () => {
|
||||
const { rerender } = render(
|
||||
<ContentTagsDropDownSelectorComponent
|
||||
key={`selector-${data.taxonomyId}`}
|
||||
taxonomyId={data.taxonomyId}
|
||||
level={data.level}
|
||||
tagsTree={data.tagsTree}
|
||||
searchTerm={initalSearchTerm}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, initalSearchTerm);
|
||||
});
|
||||
|
||||
const updatedSearchTerm = 'test 2';
|
||||
rerender(<ContentTagsDropDownSelectorComponent
|
||||
key={`selector-${data.taxonomyId}`}
|
||||
taxonomyId={data.taxonomyId}
|
||||
level={data.level}
|
||||
tagsTree={data.tagsTree}
|
||||
searchTerm={updatedSearchTerm}
|
||||
/>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useTaxonomyTagsData).toBeCalledWith(data.taxonomyId, null, 1, updatedSearchTerm);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-check
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
@@ -34,9 +35,13 @@ import TagBubble from './TagBubble';
|
||||
* }
|
||||
* };
|
||||
*
|
||||
* @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
|
||||
* @param {Object} props - The component props.
|
||||
* @param {Object} props.tagsTree - Array of taxonomy tags that are applied to the content.
|
||||
* @param {(
|
||||
* tagSelectableBoxValue: string,
|
||||
* checked: boolean
|
||||
* ) => void} props.removeTagHandler - Function that is called when removing tags from the tree.
|
||||
* @param {boolean} props.editable - Whether the tags appear with an 'x' allowing the user to remove them.
|
||||
*/
|
||||
const ContentTagsTree = ({ tagsTree, removeTagHandler, editable }) => {
|
||||
const renderTagsTree = (tag, level, lineage) => Object.keys(tag).map((key) => {
|
||||
@@ -60,7 +65,7 @@ const ContentTagsTree = ({ tagsTree, removeTagHandler, editable }) => {
|
||||
return null;
|
||||
});
|
||||
|
||||
return renderTagsTree(tagsTree, 0, []);
|
||||
return <>{renderTagsTree(tagsTree, 0, [])}</>;
|
||||
};
|
||||
|
||||
ContentTagsTree.propTypes = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { act, render, fireEvent } from '@testing-library/react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import TagBubble from './TagBubble';
|
||||
@@ -90,9 +90,7 @@ describe('<TagBubble />', () => {
|
||||
);
|
||||
|
||||
const xButton = container.getElementsByClassName('pgn__chip__icon-after')[0];
|
||||
await act(async () => {
|
||||
fireEvent.click(xButton);
|
||||
});
|
||||
fireEvent.click(xButton);
|
||||
expect(data.removeTagHandler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,21 +3,43 @@ 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;
|
||||
|
||||
/**
|
||||
* Get the URL used to fetch tags data from the "taxonomy tags" REST API
|
||||
* @param {number} taxonomyId
|
||||
* @param {{page?: number, searchTerm?: string, parentTag?: string}} options
|
||||
* @returns {string} the URL
|
||||
*/
|
||||
export const getTaxonomyTagsApiUrl = (taxonomyId, options = {}) => {
|
||||
const url = new URL(`api/content_tagging/v1/taxonomies/${taxonomyId}/tags/`, getApiBaseUrl());
|
||||
if (options.parentTag) {
|
||||
url.searchParams.append('parent_tag', options.parentTag);
|
||||
}
|
||||
if (options.page) {
|
||||
url.searchParams.append('page', String(options.page));
|
||||
}
|
||||
if (options.searchTerm) {
|
||||
url.searchParams.append('search_term', options.searchTerm);
|
||||
}
|
||||
|
||||
// Load in the full tree if children at once, if we can:
|
||||
// Note: do not combine this with page_size (we currently aren't using page_size)
|
||||
url.searchParams.append('full_depth_threshold', '1000');
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Get all tags that belong to taxonomy.
|
||||
* @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<import("./types.mjs").TaxonomyTagsData>}
|
||||
* @param {{page?: number, searchTerm?: string, parentTag?: string}} options
|
||||
* @returns {Promise<import("../../taxonomy/tag-list/data/types.mjs").TagData>}
|
||||
*/
|
||||
export async function getTaxonomyTagsData(taxonomyId, fullPathProvided) {
|
||||
const { data } = await getAuthenticatedHttpClient().get(
|
||||
fullPathProvided ? new URL(`${fullPathProvided}`) : getTaxonomyTagsApiUrl(taxonomyId),
|
||||
);
|
||||
export async function getTaxonomyTagsData(taxonomyId, options = {}) {
|
||||
const url = getTaxonomyTagsApiUrl(taxonomyId, options);
|
||||
const { data } = await getAuthenticatedHttpClient().get(url);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
@@ -46,7 +68,7 @@ export async function getContentData(contentId) {
|
||||
* @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>}
|
||||
* @returns {Promise<import("./types.mjs").ContentTaxonomyTagsData>}
|
||||
*/
|
||||
export async function updateContentTaxonomyTags(contentId, taxonomyId, tags) {
|
||||
let url = getContentTaxonomyTagsApiUrl(contentId);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-check
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
@@ -47,13 +48,33 @@ describe('content tags drawer api calls', () => {
|
||||
expect(result).toEqual(taxonomyTagsMock);
|
||||
});
|
||||
|
||||
it('should get taxonomy tags data with fullPathProvided', async () => {
|
||||
it('should get taxonomy tags data with parentTag', async () => {
|
||||
const taxonomyId = 123;
|
||||
const fullPathProvided = 'http://example.com/';
|
||||
const options = { parentTag: 'Sample Tag' };
|
||||
axiosMock.onGet().reply(200, taxonomyTagsMock);
|
||||
const result = await getTaxonomyTagsData(taxonomyId, fullPathProvided);
|
||||
const result = await getTaxonomyTagsData(taxonomyId, options);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toEqual(new URL(`${fullPathProvided}`));
|
||||
expect(axiosMock.history.get[0].url).toContain('parent_tag=Sample+Tag');
|
||||
expect(result).toEqual(taxonomyTagsMock);
|
||||
});
|
||||
|
||||
it('should get taxonomy tags data with page', async () => {
|
||||
const taxonomyId = 123;
|
||||
const options = { page: 2 };
|
||||
axiosMock.onGet().reply(200, taxonomyTagsMock);
|
||||
const result = await getTaxonomyTagsData(taxonomyId, options);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toContain('page=2');
|
||||
expect(result).toEqual(taxonomyTagsMock);
|
||||
});
|
||||
|
||||
it('should get taxonomy tags data with searchTerm', async () => {
|
||||
const taxonomyId = 123;
|
||||
const options = { searchTerm: 'memo' };
|
||||
axiosMock.onGet().reply(200, taxonomyTagsMock);
|
||||
const result = await getTaxonomyTagsData(taxonomyId, options);
|
||||
|
||||
expect(axiosMock.history.get[0].url).toContain('search_term=memo');
|
||||
expect(result).toEqual(taxonomyTagsMock);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
// @ts-check
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
useQuery,
|
||||
useQueries,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
import {
|
||||
getTaxonomyTagsData,
|
||||
getContentTaxonomyTagsData,
|
||||
@@ -7,22 +13,94 @@ import {
|
||||
updateContentTaxonomyTags,
|
||||
} from './api';
|
||||
|
||||
/** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagListData} TagListData */
|
||||
/** @typedef {import("../../taxonomy/tag-list/data/types.mjs").TagData} TagData */
|
||||
|
||||
/**
|
||||
* Builds the query to get the taxonomy tags
|
||||
* @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 {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: TagListData[],
|
||||
* }[],
|
||||
* }}
|
||||
*/
|
||||
export const useTaxonomyTagsData = (taxonomyId, fullPathProvided) => (
|
||||
useQuery({
|
||||
queryKey: [`taxonomyTags${ fullPathProvided || taxonomyId }`],
|
||||
queryFn: () => getTaxonomyTagsData(taxonomyId, fullPathProvided),
|
||||
})
|
||||
);
|
||||
export const useTaxonomyTagsData = (taxonomyId, parentTag = null, numPages = 1, searchTerm = '') => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const queryFn = async ({ queryKey }) => {
|
||||
const page = queryKey[3];
|
||||
return getTaxonomyTagsData(taxonomyId, { parentTag: parentTag || '', searchTerm, page });
|
||||
};
|
||||
|
||||
/** @type {{queryKey: any[], queryFn: typeof queryFn, staleTime: number}[]} */
|
||||
const queries = [];
|
||||
for (let page = 1; page <= numPages; page++) {
|
||||
queries.push(
|
||||
{ queryKey: ['taxonomyTags', taxonomyId, parentTag, page, searchTerm], queryFn, staleTime: Infinity },
|
||||
);
|
||||
}
|
||||
|
||||
const dataPages = useQueries({ queries });
|
||||
|
||||
const totalPages = dataPages[0]?.data?.numPages || 1;
|
||||
const hasMorePages = numPages < totalPages;
|
||||
|
||||
const tagPages = useMemo(() => {
|
||||
/** @type { { isLoading: boolean, isError: boolean, data: TagListData[] }[] } */
|
||||
const newTags = [];
|
||||
|
||||
// Pre-load desendants if possible
|
||||
const preLoadedData = new Map();
|
||||
|
||||
dataPages.forEach(result => {
|
||||
/** @type {TagListData[]} */
|
||||
const simplifiedTagsList = [];
|
||||
|
||||
result.data?.results?.forEach((tag) => {
|
||||
if (tag.parentValue === parentTag) {
|
||||
simplifiedTagsList.push(tag);
|
||||
} else if (!preLoadedData.has(tag.parentValue)) {
|
||||
preLoadedData.set(tag.parentValue, [tag]);
|
||||
} else {
|
||||
preLoadedData.get(tag.parentValue).push(tag);
|
||||
}
|
||||
});
|
||||
|
||||
newTags.push({ ...result, data: simplifiedTagsList });
|
||||
});
|
||||
|
||||
// Store the pre-loaded descendants into the query cache:
|
||||
preLoadedData.forEach((tags, parentValue) => {
|
||||
const queryKey = ['taxonomyTags', taxonomyId, parentValue, 1, searchTerm];
|
||||
/** @type {TagData} */
|
||||
const cachedData = {
|
||||
next: '',
|
||||
previous: '',
|
||||
count: tags.length,
|
||||
numPages: 1,
|
||||
currentPage: 1,
|
||||
start: 0,
|
||||
results: tags,
|
||||
};
|
||||
queryClient.setQueryData(queryKey, cachedData);
|
||||
});
|
||||
|
||||
return newTags;
|
||||
}, [dataPages]);
|
||||
|
||||
return { hasMorePages, tagPages };
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param {string} contentId The ID of the content object to fetch the applied tags for (e.g. an XBlock usage key)
|
||||
*/
|
||||
export const useContentTaxonomyTagsData = (contentId) => (
|
||||
useQuery({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueries } from '@tanstack/react-query';
|
||||
import { act } from '@testing-library/react';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import {
|
||||
useTaxonomyTagsData,
|
||||
useContentTaxonomyTagsData,
|
||||
@@ -12,7 +13,10 @@ import { updateContentTaxonomyTags } from './api';
|
||||
jest.mock('@tanstack/react-query', () => ({
|
||||
useQuery: jest.fn(),
|
||||
useMutation: jest.fn(),
|
||||
useQueryClient: jest.fn(),
|
||||
useQueryClient: jest.fn(() => ({
|
||||
setQueryData: jest.fn(),
|
||||
})),
|
||||
useQueries: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
@@ -20,20 +24,91 @@ jest.mock('./api', () => ({
|
||||
}));
|
||||
|
||||
describe('useTaxonomyTagsData', () => {
|
||||
it('should return success response', () => {
|
||||
useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
|
||||
it('should call useQueries with the correct arguments', () => {
|
||||
const taxonomyId = 123;
|
||||
const result = useTaxonomyTagsData(taxonomyId);
|
||||
const mockData = {
|
||||
results: [
|
||||
{
|
||||
value: 'tag 1',
|
||||
externalId: null,
|
||||
childCount: 16,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 635951,
|
||||
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%201',
|
||||
},
|
||||
{
|
||||
value: 'tag 2',
|
||||
externalId: null,
|
||||
childCount: 1,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 636992,
|
||||
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%202',
|
||||
},
|
||||
{
|
||||
value: 'tag 3',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 1,
|
||||
parentValue: 'tag 2',
|
||||
id: 636993,
|
||||
subTagsUrl: null,
|
||||
},
|
||||
{
|
||||
value: 'tag 4',
|
||||
externalId: null,
|
||||
childCount: 0,
|
||||
depth: 1,
|
||||
parentValue: 'tag 2',
|
||||
id: 636994,
|
||||
subTagsUrl: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(result).toEqual({ isSuccess: true, data: 'data' });
|
||||
});
|
||||
useQueries.mockReturnValue([
|
||||
{ data: mockData, isLoading: false, isError: false },
|
||||
]);
|
||||
|
||||
it('should return failure response', () => {
|
||||
useQuery.mockReturnValueOnce({ isSuccess: false });
|
||||
const taxonomyId = 123;
|
||||
const result = useTaxonomyTagsData(taxonomyId);
|
||||
const { result } = renderHook(() => useTaxonomyTagsData(taxonomyId));
|
||||
|
||||
expect(result).toEqual({ isSuccess: false });
|
||||
// Assert that useQueries was called with the correct arguments
|
||||
expect(useQueries).toHaveBeenCalledWith({
|
||||
queries: [
|
||||
{ queryKey: ['taxonomyTags', taxonomyId, null, 1, ''], queryFn: expect.any(Function), staleTime: Infinity },
|
||||
],
|
||||
});
|
||||
|
||||
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([
|
||||
{
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
data: [
|
||||
{
|
||||
value: 'tag 1',
|
||||
externalId: null,
|
||||
childCount: 16,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 635951,
|
||||
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%201',
|
||||
},
|
||||
{
|
||||
value: 'tag 2',
|
||||
externalId: null,
|
||||
childCount: 1,
|
||||
depth: 0,
|
||||
parentValue: null,
|
||||
id: 636992,
|
||||
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%202',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @typedef {Object} Tag
|
||||
* @property {string} value
|
||||
* @property {string[]} lineage
|
||||
* @typedef {Object} Tag A tag that has been applied to some content.
|
||||
* @property {string} value The value of the tag, also its ID. e.g. "Biology"
|
||||
* @property {string[]} lineage The values of the tag and its parent(s) in the hierarchy
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ContentTaxonomyTagData
|
||||
* @typedef {Object} ContentTaxonomyTagData A list of the tags from one taxonomy that are applied to a content object.
|
||||
* @property {string} name
|
||||
* @property {number} taxonomy_id
|
||||
* @property {number} taxonomyId
|
||||
* @property {boolean} editable
|
||||
* @property {Tag[]} tags
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ContentTaxonomyTagsData
|
||||
* @typedef {Object} ContentTaxonomyTagsData A list of all the tags applied to some content object, grouped by taxonomy.
|
||||
* @property {ContentTaxonomyTagData[]} taxonomies
|
||||
*/
|
||||
|
||||
@@ -30,73 +30,29 @@
|
||||
/**
|
||||
* @typedef {Object} ContentData
|
||||
* @property {string} id
|
||||
* @property {string} display_name
|
||||
* @property {string} displayName
|
||||
* @property {string} category
|
||||
* @property {boolean} has_children
|
||||
* @property {string} edited_on
|
||||
* @property {boolean} hasChildren
|
||||
* @property {string} editedOn
|
||||
* @property {boolean} published
|
||||
* @property {string} published_on
|
||||
* @property {string} studio_url
|
||||
* @property {boolean} released_to_students
|
||||
* @property {string} release_date
|
||||
* @property {string} visibility_state
|
||||
* @property {boolean} has_explicit_staff_lock
|
||||
* @property {string} publishedOn
|
||||
* @property {string} studioUrl
|
||||
* @property {boolean} releasedToStudents
|
||||
* @property {string|null} releaseDate
|
||||
* @property {string} visibilityState
|
||||
* @property {boolean} hasExplicitStaffLock
|
||||
* @property {string} start
|
||||
* @property {boolean} graded
|
||||
* @property {string} due_date
|
||||
* @property {string} dueDate
|
||||
* @property {string} due
|
||||
* @property {string} relative_weeks_due
|
||||
* @property {string} format
|
||||
* @property {boolean} has_changes
|
||||
* @property {string|null} relativeWeeksDue
|
||||
* @property {string|null} format
|
||||
* @property {boolean} hasChanges
|
||||
* @property {ContentActions} actions
|
||||
* @property {string} explanatory_message
|
||||
* @property {string} show_correctness
|
||||
* @property {boolean} discussion_enabled
|
||||
* @property {boolean} ancestor_has_staff_lock
|
||||
* @property {boolean} staff_only_message
|
||||
* @property {boolean} enable_copy_paste_units
|
||||
* @property {boolean} has_partition_group_components
|
||||
* @property {string} explanatoryMessage
|
||||
* @property {string} showCorrectness
|
||||
* @property {boolean} discussionEnabled
|
||||
* @property {boolean} ancestorHasStaffLock
|
||||
* @property {boolean} staffOnlyMessage
|
||||
* @property {boolean} hasPartitionGroupComponents
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TaxonomyTagData
|
||||
* @property {string} id
|
||||
* @property {string} display_name
|
||||
* @property {string} category
|
||||
* @property {boolean} has_children
|
||||
* @property {string} edited_on
|
||||
* @property {boolean} published
|
||||
* @property {string} published_on
|
||||
* @property {string} studio_url
|
||||
* @property {boolean} released_to_students
|
||||
* @property {string} release_date
|
||||
* @property {string} visibility_state
|
||||
* @property {boolean} has_explicit_staff_lock
|
||||
* @property {string} start
|
||||
* @property {boolean} graded
|
||||
* @property {string} due_date
|
||||
* @property {string} due
|
||||
* @property {string} relative_weeks_due
|
||||
* @property {string} format
|
||||
* @property {boolean} has_changes
|
||||
* @property {ContentActions} actions
|
||||
* @property {string} explanatory_message
|
||||
* @property {string} show_correctness
|
||||
* @property {boolean} discussion_enabled
|
||||
* @property {boolean} ancestor_has_staff_lock
|
||||
* @property {boolean} staff_only_message
|
||||
* @property {boolean} enable_copy_paste_units
|
||||
* @property {boolean} has_partition_group_components
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} TaxonomyTagsData
|
||||
* @property {string} next
|
||||
* @property {string} previous
|
||||
* @property {number} count
|
||||
* @property {number} num_pages
|
||||
* @property {number} current_page
|
||||
* @property {number} start
|
||||
* @property {TaxonomyTagData[]} results
|
||||
*/
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.load-more-tags.button',
|
||||
defaultMessage: 'Load more',
|
||||
},
|
||||
taxonomyTagsCheckboxAriaLabel: {
|
||||
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.selectable-box.aria.label',
|
||||
defaultMessage: '{tag} checkbox',
|
||||
},
|
||||
taxonomyTagsAriaLabel: {
|
||||
id: 'course-authoring.content-tags-drawer.content-tags-collapsible.selectable-box.selection.aria.label',
|
||||
defaultMessage: 'taxonomy tags selection',
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
* @property {number} id
|
||||
* @property {string} name
|
||||
* @property {boolean} enabled
|
||||
* @property {boolean} allow_multiple
|
||||
* @property {boolean} allow_free_text
|
||||
* @property {boolean} system_defined
|
||||
* @property {boolean} visible_to_authors
|
||||
* @property {boolean} allowMultiple
|
||||
* @property {boolean} allowFreeText
|
||||
* @property {boolean} systemDefined
|
||||
* @property {boolean} visibleToAuthors
|
||||
* @property {string[]} orgs
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -16,10 +17,9 @@
|
||||
* @property {string} next
|
||||
* @property {string} previous
|
||||
* @property {number} count
|
||||
* @property {number} num_pages
|
||||
* @property {number} current_page
|
||||
* @property {number} numPages
|
||||
* @property {number} currentPage
|
||||
* @property {number} start
|
||||
* @property {function} refetch
|
||||
* @property {TaxonomyData[]} results
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// @ts-check
|
||||
|
||||
// TODO: this file needs to be merged into src/taxonomy/data/api.js
|
||||
// We are creating a mess with so many different /data/[api|types].js files in subfolders.
|
||||
// There is only one tagging/taxonomy API, and it should be implemented via a single types.mjs and api.js file.
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
@@ -9,7 +14,6 @@ const getTagListApiUrl = (taxonomyId, page) => new URL(
|
||||
getApiBaseUrl(),
|
||||
).href;
|
||||
|
||||
// ToDo: fix types
|
||||
/**
|
||||
* @param {number} taxonomyId
|
||||
* @param {import('./types.mjs').QueryOptions} options
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
// @ts-check
|
||||
|
||||
// TODO: this file needs to be merged into src/taxonomy/data/types.mjs
|
||||
// We are creating a mess with so many different /data/[api|types].js files in subfolders.
|
||||
// There is only one tagging/taxonomy API, and it should be implemented via a single types.mjs and api.js file.
|
||||
|
||||
/**
|
||||
* @typedef {Object} QueryOptions
|
||||
* @property {number} pageIndex
|
||||
*/
|
||||
|
||||
// FIXME: this should be renamed to TagData
|
||||
/**
|
||||
* @typedef {Object} TagListData
|
||||
* @property {number} childCount
|
||||
@@ -13,9 +18,12 @@
|
||||
* @property {number} id
|
||||
* @property {string | null} parentValue
|
||||
* @property {string | null} subTagsUrl
|
||||
* @property {string} value
|
||||
* @property {string} value Unique ID for this tag, also its display text
|
||||
* @property {number?} usageCount
|
||||
* @property {string?} _id Database ID. Don't rely on this, as it is not present for free-text tags.
|
||||
*/
|
||||
|
||||
// FIXME: this should be renamed to TagListData
|
||||
/**
|
||||
* @typedef {Object} TagData
|
||||
* @property {number} count
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// @ts-check
|
||||
|
||||
// TODO: this file needs to be merged into src/taxonomy/data/api.js
|
||||
// We are creating a mess with so many different /data/[api|types].js files in subfolders.
|
||||
// There is only one tagging/taxonomy API, and it should be implemented via a single types.mjs and api.js file.
|
||||
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@@ -11,7 +16,7 @@ const getTaxonomyDetailApiUrl = (taxonomyId) => new URL(
|
||||
|
||||
/**
|
||||
* @param {number} taxonomyId
|
||||
* @returns {import('@tanstack/react-query').UseQueryResult<import('./types.mjs').TaxonomyData>}
|
||||
* @returns {import('@tanstack/react-query').UseQueryResult<import('../../data/types.mjs').TaxonomyData>}
|
||||
*/ // eslint-disable-next-line import/prefer-default-export
|
||||
export const useTaxonomyDetailData = (taxonomyId) => (
|
||||
useQuery({
|
||||
|
||||
@@ -24,7 +24,7 @@ export const useTaxonomyDetailDataStatus = (taxonomyId) => {
|
||||
|
||||
/**
|
||||
* @param {number} taxonomyId
|
||||
* @returns {import("./types.mjs").TaxonomyData | undefined}
|
||||
* @returns {import("../../data/types.mjs").TaxonomyData | undefined}
|
||||
*/
|
||||
export const useTaxonomyDetailDataResponse = (taxonomyId) => {
|
||||
const { isSuccess, data } = useTaxonomyDetailData(taxonomyId);
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @typedef {Object} TaxonomyData
|
||||
* @property {number} id
|
||||
* @property {string} name
|
||||
* @property {boolean} enabled
|
||||
* @property {boolean} allowMultiple
|
||||
* @property {boolean} allowFreeText
|
||||
* @property {boolean} systemDefined
|
||||
* @property {boolean} visibleToAuthors
|
||||
* @property {string[]} orgs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} UseQueryResult
|
||||
* @property {Object} data
|
||||
* @property {string} status
|
||||
*/
|
||||
Reference in New Issue
Block a user