feat: Implement Content Tags Drawer

This implements a side drawer widget for content taxonomy tags.
It includes displaying the object's tags, along with their
lineage (ancestor tags) data. It also implements the listing the
available taxonomy tags (including nesting ones) to select from
to apply to this unit.

Note: The editing of tags (adding/removing) will be added in a future
PR.

* feat: Add initial UnitTaxonomyTagsDrawer widget
* feat: Add fetching unit taxonomy tags from backend
* feat: Add fetching/group tags with taxonomies
* feat: Add fetch Unit data and display name
* feat: Add Taxonomy Tags dropdown selector
* feat: Add TagBubble for tag styling
* chore: Add distinct keys to elements + remove logs
* feat: Add close drawer with ESC- keypress
* feat: Make dropdown selectors keyboard accessible
* chore: Fix issues causing validation to fail
* test: Add coverage tests for UnitTaxonomyDrawer
* feat: Incorporate tags lineage data from API
* refactor: Remove/replace deprecated injectIntl
* test: Remove redux store related code + fix warnings
* feat: Use <Loading /> instead of loading string
* docs: Add docs string to TaxonomyTagsCollapsible
* feat: Use <Spinner/> to allow mutiple loading to show
* feat: Rename UnitTaxonomyTagDrawer -> ContentTagsDrawer
* feat: Add ContentTagsTree component to render Tags
* feat: Only fetch tags when dropdowns are opened
* refactor: Simply dropdown close/open states
* feat: Use built in class styles instead of custom
* feat: Replace hardcoded values with scss variables
* refactor: follow existing structure for reactQuery/APIs
* feat: Change tag bubble outline color
* feat: Add TagOutlineIcon for implicit tags
* feat: Make aria label internationalized
* feat: Replace custom styles with builtin classes
* fix: Fix bug with closing drawer
* refactor: Simplify content tags fetching code
* refactor: Simplify getTaxonomyListApiUrl
This commit is contained in:
Yusuf Musleh
2023-10-23 17:18:50 +03:00
committed by Adolfo R. Brandes
parent 9b053de0b7
commit 375006deb1
32 changed files with 1686 additions and 15 deletions

View File

@@ -0,0 +1,117 @@
import React from 'react';
import {
Badge,
Collapsible,
SelectableBox,
Button,
ModalPopup,
useToggle,
} from '@edx/paragon';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import messages from './messages';
import './ContentTagsCollapsible.scss';
import ContentTagsDropDownSelector from './ContentTagsDropDownSelector';
import ContentTagsTree from './ContentTagsTree';
/**
* 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.
* @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)
*/
const ContentTagsCollapsible = ({ taxonomyAndTagsData }) => {
const intl = useIntl();
const {
id, name, contentTags,
} = taxonomyAndTagsData;
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = React.useState(null);
return (
<div className="d-flex">
<Collapsible title={name} styling="card-lg" className="taxonomy-tags-collapsible">
<div key={id}>
<ContentTagsTree appliedContentTags={contentTags} />
</div>
<div className="d-flex taxonomy-tags-selector-menu">
<Button
ref={setTarget}
variant="outline-primary"
onClick={open}
>
<FormattedMessage {...messages.addTagsButtonText} />
</Button>
</div>
<ModalPopup
hasArrow
placement="bottom"
positionRef={target}
isOpen={isOpen}
onClose={close}
>
<div className="bg-white p-3 shadow">
<SelectableBox.Set
type="checkbox"
name="tags"
columns={1}
ariaLabel={intl.formatMessage(messages.taxonomyTagsAriaLabel)}
className="taxonomy-tags-selectable-box-set"
>
<ContentTagsDropDownSelector
key={`selector-${id}`}
taxonomyId={id}
level={0}
/>
</SelectableBox.Set>
</div>
</ModalPopup>
</Collapsible>
<div className="d-flex">
<Badge
variant="light"
pill
className={classNames('align-self-start', 'mt-3', {
// eslint-disable-next-line quote-props
'invisible': contentTags.length === 0,
})}
>
{contentTags.length}
</Badge>
</div>
</div>
);
};
ContentTagsCollapsible.propTypes = {
taxonomyAndTagsData: PropTypes.shape({
id: PropTypes.number,
name: PropTypes.string,
contentTags: PropTypes.arrayOf(PropTypes.shape({
value: PropTypes.string,
lineage: PropTypes.arrayOf(PropTypes.string),
})),
}).isRequired,
};
export default ContentTagsCollapsible;

View File

@@ -0,0 +1,24 @@
.taxonomy-tags-collapsible {
flex: 1;
border: none !important;
.collapsible-trigger {
border: none !important;
}
}
.taxonomy-tags-selector-menu {
button {
flex: 1;
}
}
.taxonomy-tags-selector-menu + div {
width: 100%;
}
.taxonomy-tags-selectable-box-set {
grid-auto-rows: unset !important;
overflow-y: scroll;
max-height: 20rem;
}

View File

@@ -0,0 +1,63 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { act, render } from '@testing-library/react';
import PropTypes from 'prop-types';
import ContentTagsCollapsible from './ContentTagsCollapsible';
jest.mock('./data/apiHooks', () => ({
useTaxonomyTagsDataResponse: jest.fn(),
useIsTaxonomyTagsDataLoaded: jest.fn(),
}));
const data = {
id: 123,
name: 'Taxonomy 1',
contentTags: [
{
value: 'Tag 1',
lineage: ['Tag 1'],
},
{
value: 'Tag 2',
lineage: ['Tag 2'],
},
],
};
const ContentTagsCollapsibleComponent = ({ taxonomyAndTagsData }) => (
<IntlProvider locale="en" messages={{}}>
<ContentTagsCollapsible taxonomyAndTagsData={taxonomyAndTagsData} />
</IntlProvider>
);
ContentTagsCollapsibleComponent.propTypes = {
taxonomyAndTagsData: PropTypes.shape({
id: PropTypes.number,
name: PropTypes.string,
contentTags: PropTypes.arrayOf(PropTypes.shape({
value: PropTypes.string,
lineage: PropTypes.arrayOf(PropTypes.string),
})),
}).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} />);
expect(getByText('Taxonomy 1')).toBeInTheDocument();
expect(container.getElementsByClassName('badge').length).toBe(1);
expect(getByText('2')).toBeInTheDocument();
});
});
it('should render taxonomy tags data without tags number badge', async () => {
data.contentTags = [];
await act(async () => {
const { container, getByText } = render(<ContentTagsCollapsibleComponent taxonomyAndTagsData={data} />);
expect(getByText('Taxonomy 1')).toBeInTheDocument();
expect(container.getElementsByClassName('invisible').length).toBe(1);
});
});
});

View File

@@ -0,0 +1,127 @@
import React, { useMemo, useEffect } from 'react';
import {
Container,
CloseButton,
Spinner,
} from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useParams } from 'react-router-dom';
import messages from './messages';
import ContentTagsCollapsible from './ContentTagsCollapsible';
import { extractOrgFromContentId } from './utils';
import {
useContentTaxonomyTagsDataResponse,
useIsContentTaxonomyTagsDataLoaded,
useContentDataResponse,
useIsContentDataLoaded,
} from './data/apiHooks';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks';
import Loading from '../generic/Loading';
const ContentTagsDrawer = () => {
const intl = useIntl();
const { contentId } = useParams();
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 { taxonomyListData, isTaxonomyListLoaded } = useTaxonomyListData();
const closeContentTagsDrawer = () => {
// "*" allows communication with any origin
window.parent.postMessage('closeManageTagsDrawer', '*');
};
useEffect(() => {
const handleEsc = (event) => {
/* Close drawer when ESC-key is pressed and selectable dropdown box not open */
const selectableBoxOpen = document.querySelector('[data-selectable-box="taxonomy-tags"]');
if (event.key === 'Escape' && !selectableBoxOpen) {
closeContentTagsDrawer();
}
};
document.addEventListener('keydown', handleEsc);
return () => {
document.removeEventListener('keydown', handleEsc);
};
}, []);
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 contentTaxonomies = contentTaxonomyTagsData.taxonomies;
// eslint-disable-next-line array-callback-return
contentTaxonomies.map((contentTaxonomyTags) => {
const contentTaxonomy = taxonomiesList.find((taxonomy) => taxonomy.id === contentTaxonomyTags.taxonomyId);
if (contentTaxonomy) {
contentTaxonomy.contentTags = contentTaxonomyTags.tags;
}
});
return taxonomiesList;
}
return [];
}, [taxonomyListData, contentTaxonomyTagsData]);
return (
<div className="mt-1">
<Container size="xl">
<CloseButton onClick={() => closeContentTagsDrawer()} data-testid="drawer-close-button" />
<span>{intl.formatMessage(messages.headerSubtitle)}</span>
{ isContentDataLoaded
? <h3>{ contentData.displayName }</h3>
: (
<div className="d-flex justify-content-center align-items-center flex-column">
<Spinner
animation="border"
size="xl"
screenReaderText={intl.formatMessage(messages.loadingMessage)}
/>
</div>
)}
<hr />
{ isTaxonomyListLoaded && isContentTaxonomyTagsLoaded
? taxonomies.map((data) => (
<div key={`taxonomy-tags-collapsible-${data.id}`}>
<ContentTagsCollapsible taxonomyAndTagsData={data} />
<hr />
</div>
))
: <Loading />}
</Container>
</div>
);
};
export default ContentTagsDrawer;

View File

@@ -0,0 +1,186 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { act, render, fireEvent } from '@testing-library/react';
import ContentTagsDrawer from './ContentTagsDrawer';
import {
useContentTaxonomyTagsDataResponse,
useIsContentTaxonomyTagsDataLoaded,
useContentDataResponse,
useIsContentDataLoaded,
} from './data/apiHooks';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
contentId: 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab',
}),
}));
jest.mock('./data/apiHooks', () => ({
useContentTaxonomyTagsDataResponse: jest.fn(),
useIsContentTaxonomyTagsDataLoaded: jest.fn(),
useContentDataResponse: jest.fn(),
useIsContentDataLoaded: jest.fn(),
useTaxonomyTagsDataResponse: jest.fn(),
useIsTaxonomyTagsDataLoaded: jest.fn(),
}));
jest.mock('../taxonomy/data/apiHooks', () => ({
useTaxonomyListDataResponse: jest.fn(),
useIsTaxonomyListDataLoaded: jest.fn(),
}));
const RootWrapper = () => (
<IntlProvider locale="en" messages={{}}>
<ContentTagsDrawer />
</IntlProvider>
);
describe('<ContentTagsDrawer />', () => {
it('should render page and page title correctly', () => {
const { getByText } = render(<RootWrapper />);
expect(getByText('Manage tags')).toBeInTheDocument();
});
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];
expect(spinner.textContent).toEqual('Loading'); // Uses <Spinner />
});
});
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];
expect(spinner.textContent).toEqual('Loading...'); // Uses <Loading />
});
});
it('shows the content display name after the query is complete', async () => {
useIsContentDataLoaded.mockReturnValue(true);
useContentDataResponse.mockReturnValue({
displayName: 'Unit 1',
});
await act(async () => {
const { getByText } = render(<RootWrapper />);
expect(getByText('Unit 1')).toBeInTheDocument();
});
});
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'],
},
],
},
],
});
useTaxonomyListDataResponse.mockReturnValue({
results: [{
id: 123,
name: 'Taxonomy 1',
description: 'This is a description 1',
}, {
id: 124,
name: 'Taxonomy 2',
description: 'This is a description 2',
}],
});
await act(async () => {
const { container, getByText } = render(<RootWrapper />);
expect(getByText('Taxonomy 1')).toBeInTheDocument();
expect(getByText('Taxonomy 2')).toBeInTheDocument();
const tagCountBadges = container.getElementsByClassName('badge');
expect(tagCountBadges[0].textContent).toBe('2');
expect(tagCountBadges[1].textContent).toBe('1');
});
});
it('should call closeContentTagsDrawer when CloseButton is clicked', async () => {
const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
const { getByTestId } = render(<RootWrapper />);
// 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);
});
expect(postMessageSpy).toHaveBeenCalledWith('closeManageTagsDrawer', '*');
postMessageSpy.mockRestore();
});
it('should call closeContentTagsDrawer when Escape key is pressed and no selectable box is active', () => {
const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
const { container } = render(<RootWrapper />);
act(() => {
fireEvent.keyDown(container, {
key: 'Escape',
});
});
expect(postMessageSpy).toHaveBeenCalledWith('closeManageTagsDrawer', '*');
postMessageSpy.mockRestore();
});
it('should not call closeContentTagsDrawer when Escape key is pressed and a selectable box is active', () => {
const postMessageSpy = jest.spyOn(window.parent, 'postMessage');
const { container } = render(<RootWrapper />);
// Simulate that the selectable box is open by adding an element with the data attribute
const selectableBox = document.createElement('div');
selectableBox.setAttribute('data-selectable-box', 'taxonomy-tags');
document.body.appendChild(selectableBox);
act(() => {
fireEvent.keyDown(container, {
key: 'Escape',
});
});
expect(postMessageSpy).not.toHaveBeenCalled();
// Remove the added element
document.body.removeChild(selectableBox);
postMessageSpy.mockRestore();
});
});

View File

@@ -0,0 +1,95 @@
import React, { useState } from 'react';
import {
SelectableBox,
Icon,
Spinner,
} from '@edx/paragon';
import { useIntl } 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';
const ContentTagsDropDownSelector = ({
taxonomyId, level, subTagsUrl,
}) => {
const intl = useIntl();
// This object represents the states of the dropdowns on this level
// The keys represent the index of the dropdown with
// the value true (open) false (closed)
const [dropdownStates, setDropdownStates] = useState({});
const isOpen = (i) => dropdownStates[i];
const clickAndEnterHandler = (i) => {
// This flips the state of the dropdown at index false (closed) -> true (open)
// and vice versa. Initially they are undefined which is falsy.
setDropdownStates({ ...dropdownStates, [i]: !dropdownStates[i] });
};
const taxonomyTagsData = useTaxonomyTagsDataResponse(taxonomyId, subTagsUrl);
const isTaxonomyTagsLoaded = useIsTaxonomyTagsDataLoaded(taxonomyId, subTagsUrl);
return (
isTaxonomyTagsLoaded && taxonomyTagsData
? taxonomyTagsData.results.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"
>
{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>
)}
</div>
{ taxonomyTag.subTagsUrl && isOpen(i) && (
<ContentTagsDropDownSelector
key={`selector-${taxonomyTag.value}`}
taxonomyId={taxonomyId}
subTagsUrl={taxonomyTag.subTagsUrl}
level={level + 1}
/>
)}
</div>
))
: (
<div className="d-flex justify-content-center align-items-center flex-column">
<Spinner
animation="border"
size="xl"
screenReaderText={intl.formatMessage(messages.loadingTagsDropdownMessage)}
/>
</div>
)
);
};
ContentTagsDropDownSelector.defaultProps = {
subTagsUrl: undefined,
};
ContentTagsDropDownSelector.propTypes = {
taxonomyId: PropTypes.number.isRequired,
level: PropTypes.number.isRequired,
subTagsUrl: PropTypes.string,
};
export default ContentTagsDropDownSelector;

View File

@@ -0,0 +1,8 @@
.taxonomy-tags-arrow-drop-down {
cursor: pointer;
}
.pgn__selectable_box.taxonomy-tags-selectable-box {
box-shadow: none;
padding: 0;
}

View File

@@ -0,0 +1,96 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { act, render } from '@testing-library/react';
import PropTypes from 'prop-types';
import ContentTagsDropDownSelector from './ContentTagsDropDownSelector';
import { useTaxonomyTagsDataResponse, useIsTaxonomyTagsDataLoaded } from './data/apiHooks';
jest.mock('./data/apiHooks', () => ({
useTaxonomyTagsDataResponse: jest.fn(),
useIsTaxonomyTagsDataLoaded: jest.fn(),
}));
const data = {
taxonomyId: 123,
level: 0,
};
const TaxonomyTagsDropDownSelectorComponent = ({
taxonomyId, level, subTagsUrl,
}) => (
<IntlProvider locale="en" messages={{}}>
<ContentTagsDropDownSelector
taxonomyId={taxonomyId}
level={level}
subTagsUrl={subTagsUrl}
/>
</IntlProvider>
);
TaxonomyTagsDropDownSelectorComponent.defaultProps = {
subTagsUrl: undefined,
};
TaxonomyTagsDropDownSelectorComponent.propTypes = {
taxonomyId: PropTypes.number.isRequired,
level: PropTypes.number.isRequired,
subTagsUrl: PropTypes.string,
};
describe('<ContentTagsDropDownSelector />', () => {
it('should render taxonomy tags drop down selector loading with spinner', async () => {
useIsTaxonomyTagsDataLoaded.mockReturnValue(false);
await act(async () => {
const { getByRole } = render(
<TaxonomyTagsDropDownSelectorComponent
taxonomyId={data.taxonomyId}
level={data.level}
/>,
);
const spinner = getByRole('status');
expect(spinner.textContent).toEqual('Loading tags'); // Uses <Spinner />
});
});
it('should render taxonomy tags drop down selector with no sub tags', async () => {
useIsTaxonomyTagsDataLoaded.mockReturnValue(true);
useTaxonomyTagsDataResponse.mockReturnValue({
results: [{
value: 'Tag 1',
subTagsUrl: null,
}],
});
await act(async () => {
const { container, getByText } = render(
<TaxonomyTagsDropDownSelectorComponent
key={`selector-${data.taxonomyId}`}
taxonomyId={data.taxonomyId}
level={data.level}
/>,
);
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',
}],
});
await act(async () => {
const { container, getByText } = render(
<TaxonomyTagsDropDownSelectorComponent
taxonomyId={data.taxonomyId}
level={data.level}
/>,
);
expect(getByText('Tag 2')).toBeInTheDocument();
expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1);
});
});
});

View File

@@ -0,0 +1,88 @@
import React, { useMemo } 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:
*
* 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": {
* "Genetics Subcategory": {
* "DNA Sequencing": {}
* },
* "Molecular, Cellular, and Microbiology": {
* "Virology": {}
* }
* }
* }
*
* @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)
*/
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) => {
if (tag[key] !== undefined) {
return (
<div key={`tag-${key}-level-${level}`}>
<TagBubble
key={`tag-${key}`}
value={key}
implicit={Object.keys(tag[key]).length !== 0}
level={level}
/>
{ renderTagsTree(tag[key], level + 1) }
</div>
);
}
return null;
});
return renderTagsTree(tagsTree, 0);
};
ContentTagsTree.propTypes = {
appliedContentTags: PropTypes.arrayOf(PropTypes.shape({
value: PropTypes.string,
lineage: PropTypes.arrayOf(PropTypes.string),
})).isRequired,
};
export default ContentTagsTree;

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { act, render } from '@testing-library/react';
import PropTypes from 'prop-types';
import ContentTagsTree from './ContentTagsTree';
const data = [
{
value: 'DNA Sequencing',
lineage: [
'Science and Research',
'Genetics Subcategory',
'DNA Sequencing',
],
},
{
value: 'Virology',
lineage: [
'Science and Research',
'Molecular, Cellular, and Microbiology',
'Virology',
],
},
];
const ContentTagsTreeComponent = ({ appliedContentTags }) => (
<IntlProvider locale="en" messages={{}}>
<ContentTagsTree appliedContentTags={appliedContentTags} />
</IntlProvider>
);
ContentTagsTreeComponent.propTypes = {
appliedContentTags: PropTypes.arrayOf(PropTypes.shape({
value: PropTypes.string,
lineage: PropTypes.arrayOf(PropTypes.string),
})).isRequired,
};
describe('<ContentTagsTree />', () => {
it('should render taxonomy tags data along content tags number badge', async () => {
await act(async () => {
const { getByText } = render(<ContentTagsTreeComponent appliedContentTags={data} />);
expect(getByText('Science and Research')).toBeInTheDocument();
expect(getByText('Genetics Subcategory')).toBeInTheDocument();
expect(getByText('Molecular, Cellular, and Microbiology')).toBeInTheDocument();
expect(getByText('DNA Sequencing')).toBeInTheDocument();
expect(getByText('Virology')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,42 @@
import React from 'react';
import {
Button,
} from '@edx/paragon';
import { Tag, Close } from '@edx/paragon/icons';
import PropTypes from 'prop-types';
import TagOutlineIcon from './TagOutlineIcon';
const TagBubble = ({
value, subTagsCount, implicit, level,
}) => {
const className = `tag-bubble mb-2 ${implicit ? 'implicit' : ''}`;
const tagIcon = () => (implicit ? <TagOutlineIcon className="implicit-tag-icon" /> : <Tag />);
return (
<div style={{ paddingLeft: `${level * 1}rem` }}>
<Button
className={className}
variant="outline-dark"
iconBefore={tagIcon}
iconAfter={!implicit ? Close : null}
>
{value} {subTagsCount > 0 ? `(${subTagsCount})` : null }
</Button>
</div>
);
};
TagBubble.defaultProps = {
subTagsCount: 0,
implicit: true,
level: 0,
};
TagBubble.propTypes = {
value: PropTypes.string.isRequired,
subTagsCount: PropTypes.number,
implicit: PropTypes.bool,
level: PropTypes.number,
};
export default TagBubble;

View File

@@ -0,0 +1,13 @@
.tag-bubble.btn-outline-dark {
border-color: $light-300;
&:hover {
color: $white;
background-color: $dark;
border-color: $dark;
}
}
.implicit > .implicit-tag-icon {
color: $dark;
}

View File

@@ -0,0 +1,66 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { render } from '@testing-library/react';
import PropTypes from 'prop-types';
import TagBubble from './TagBubble';
const data = {
value: 'Tag 1',
};
const TagBubbleComponent = ({ value, subTagsCount, implicit }) => (
<IntlProvider locale="en" messages={{}}>
<TagBubble value={value} subTagsCount={subTagsCount} implicit={implicit} />
</IntlProvider>
);
TagBubbleComponent.defaultProps = {
subTagsCount: 0,
implicit: true,
};
TagBubbleComponent.propTypes = {
value: PropTypes.string.isRequired,
subTagsCount: PropTypes.number,
implicit: PropTypes.bool,
};
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,
};
const { container, getByText } = render(
<TagBubbleComponent
value={tagBubbleData.value}
subTagsCount={tagBubbleData.subTagsCount}
/>,
);
expect(getByText(`${tagBubbleData.value} (${tagBubbleData.subTagsCount})`)).toBeInTheDocument();
expect(container.getElementsByClassName('implicit').length).toBe(1);
});
it('should render value of the explicit tag with no sub tags', () => {
const tagBubbleData = {
implicit: false,
...data,
};
const { container, getByText } = render(
<TagBubbleComponent
value={tagBubbleData.value}
implicit={tagBubbleData.implicit}
/>,
);
expect(getByText(`${tagBubbleData.value}`)).toBeInTheDocument();
expect(container.getElementsByClassName('implicit').length).toBe(0);
expect(container.getElementsByClassName('btn-icon-after').length).toBe(1);
});
});

View File

@@ -0,0 +1,21 @@
const TagOutlineIcon = (props) => (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24px"
viewBox="0 0 24 24"
width="24px"
fill="currentColor"
role="img"
focusable="false"
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"
/>
<circle cx="6.5" cy="6.5" r="1.5" />
</svg>
);
export default TagOutlineIcon;

View File

@@ -0,0 +1,63 @@
module.exports = {
id: 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab',
displayName: 'Unit 1.1.2',
category: 'vertical',
hasChildren: true,
editedOn: 'Nov 12, 2023 at 09:53 UTC',
published: false,
publishedOn: null,
studioUrl: '/container/block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab',
releasedToStudents: false,
releaseDate: null,
visibilityState: 'needs_attention',
hasExplicitStaffLock: false,
start: '2030-01-01T00:00:00Z',
graded: false,
dueDate: '',
due: null,
relativeWeeksDue: null,
format: null,
courseGraders: [
'Homework',
'Lab',
'Midterm Exam',
'Final Exam',
],
hasChanges: true,
actions: {
deletable: true,
draggable: true,
childAddable: true,
duplicable: true,
},
explanatoryMessage: null,
groupAccess: {},
userPartitions: [
{
id: 50,
name: 'Enrollment Track Groups',
scheme: 'enrollment_track',
groups: [
{
id: 1,
name: 'Audit',
selected: false,
deleted: false,
},
],
},
],
showCorrectness: 'always',
discussionEnabled: true,
ancestorHasStaffLock: false,
taxonomyTagsWidgetUrl: 'http://localhost:2001/tagging/components/widget/',
staffOnlyMessage: false,
enableCopyPasteUnits: true,
useTaggingTaxonomyListPage: true,
hasPartitionGroupComponents: false,
userPartitionInfo: {
selectablePartitions: [],
selectedPartitionIndex: -1,
selectedGroupsLabel: '',
},
};

View File

@@ -0,0 +1,50 @@
module.exports = {
'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b': {
taxonomies: [
{
name: 'FlatTaxonomy',
taxonomyId: 3,
editable: true,
tags: [
{
value: 'flat taxonomy tag 3856',
lineage: [
'flat taxonomy tag 3856',
],
},
],
},
{
name: 'HierarchicalTaxonomy',
taxonomyId: 4,
editable: true,
tags: [
{
value: 'hierarchical taxonomy tag 1.7.59',
lineage: [
'hierarchical taxonomy tag 1',
'hierarchical taxonomy tag 1.7',
'hierarchical taxonomy tag 1.7.59',
],
},
{
value: 'hierarchical taxonomy tag 2.13.46',
lineage: [
'hierarchical taxonomy tag 2',
'hierarchical taxonomy tag 2.13',
'hierarchical taxonomy tag 2.13.46',
],
},
{
value: 'hierarchical taxonomy tag 3.4.50',
lineage: [
'hierarchical taxonomy tag 3',
'hierarchical taxonomy tag 3.4',
'hierarchical taxonomy tag 3.4.50',
],
},
],
},
],
},
};

View File

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

View File

@@ -0,0 +1,46 @@
module.exports = {
next: null,
previous: null,
count: 4,
numPages: 1,
currentPage: 1,
start: 0,
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: 16,
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: 16,
depth: 0,
parentValue: null,
id: 638033,
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%203',
},
{
value: 'tag 4',
externalId: null,
childCount: 16,
depth: 0,
parentValue: null,
id: 639074,
subTagsUrl: 'http://localhost:18010/api/content_tagging/v1/taxonomies/4/tags/?parent_tag=tag%204',
},
],
};

View File

@@ -0,0 +1,42 @@
// @ts-check
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getTaxonomyTagsApiUrl = (taxonomyId) => new URL(`api/content_tagging/v1/taxonomies/${taxonomyId}/tags/`, getApiBaseUrl()).href;
export const 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 {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 {Promise<Object>}
*/
export async function getTaxonomyTagsData(taxonomyId, fullPathProvided) {
const { data } = await getAuthenticatedHttpClient().get(
fullPathProvided ? new URL(`${fullPathProvided}`) : getTaxonomyTagsApiUrl(taxonomyId),
);
return camelCaseObject(data);
}
/**
* Get the tags that are applied to the content object
* @param {string} contentId The id of the content object to fetch the applied tags for
* @returns {Promise<Object>}
*/
export async function getContentTaxonomyTagsData(contentId) {
const { data } = await getAuthenticatedHttpClient().get(getContentTaxonomyTagsApiUrl(contentId));
return camelCaseObject(data[contentId]);
}
/**
* Fetch meta data (eg: display_name) about the content object (unit/compoenent)
* @param {string} contentId The id of the content object (unit/component)
* @returns {Promise<Object>}
*/
export async function getContentData(contentId) {
const { data } = await getAuthenticatedHttpClient().get(getContentDataApiUrl(contentId));
return camelCaseObject(data);
}

View File

@@ -0,0 +1,71 @@
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 {
getTaxonomyTagsApiUrl,
getContentTaxonomyTagsApiUrl,
getContentDataApiUrl,
getTaxonomyTagsData,
getContentTaxonomyTagsData,
getContentData,
} from './api';
let axiosMock;
describe('content tags drawer api calls', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
afterEach(() => {
jest.clearAllMocks();
});
it('should get taxonomy tags data', async () => {
const taxonomyId = '123';
axiosMock.onGet().reply(200, taxonomyTagsMock);
const result = await getTaxonomyTagsData(taxonomyId);
expect(axiosMock.history.get[0].url).toEqual(getTaxonomyTagsApiUrl(taxonomyId));
expect(result).toEqual(taxonomyTagsMock);
});
it('should get taxonomy tags data with fullPathProvided', async () => {
const taxonomyId = '123';
const fullPathProvided = 'http://example.com/';
axiosMock.onGet().reply(200, taxonomyTagsMock);
const result = await getTaxonomyTagsData(taxonomyId, fullPathProvided);
expect(axiosMock.history.get[0].url).toEqual(new URL(`${fullPathProvided}`));
expect(result).toEqual(taxonomyTagsMock);
});
it('should get content taxonomy tags data', async () => {
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
axiosMock.onGet(getContentTaxonomyTagsApiUrl(contentId)).reply(200, contentTaxonomyTagsMock);
const result = await getContentTaxonomyTagsData(contentId);
expect(axiosMock.history.get[0].url).toEqual(getContentTaxonomyTagsApiUrl(contentId));
expect(result).toEqual(contentTaxonomyTagsMock[contentId]);
});
it('should get content data', async () => {
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b';
axiosMock.onGet(getContentDataApiUrl(contentId)).reply(200, contentDataMock);
const result = await getContentData(contentId);
expect(axiosMock.history.get[0].url).toEqual(getContentDataApiUrl(contentId));
expect(result).toEqual(contentDataMock);
});
});

View File

@@ -0,0 +1,111 @@
// @ts-check
import { useQuery } from '@tanstack/react-query';
import { getTaxonomyTagsData, getContentTaxonomyTagsData, getContentData } from './api';
/**
* Builds the query to get the taxonomy tags
* @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").UseQueryResult}
*/
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}
*/
const useContentTaxonomyTagsData = (contentId) => (
useQuery({
queryKey: ['contentTaxonomyTags'],
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}
*/
const useContentData = (contentId) => (
useQuery({
queryKey: ['contentData'],
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}
*/
export const useContentDataResponse = (contentId) => {
const response = useContentData(contentId);
if (response.status === 'success') {
return response.data;
}
return undefined;
};
/**
* 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'
);

View File

@@ -0,0 +1,121 @@
import { useQuery } from '@tanstack/react-query';
import {
useTaxonomyTagsDataResponse,
useIsTaxonomyTagsDataLoaded,
useContentTaxonomyTagsDataResponse,
useIsContentTaxonomyTagsDataLoaded,
useContentDataResponse,
useIsContentDataLoaded,
} from './apiHooks';
jest.mock('@tanstack/react-query', () => ({
useQuery: 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);
expect(result).toEqual({ data: 'data' });
});
it('should return undefined when status is not success', () => {
useQuery.mockReturnValueOnce({ status: 'error' });
const taxonomyId = '123';
const result = useTaxonomyTagsDataResponse(taxonomyId);
expect(result).toBeUndefined();
});
});
describe('useIsTaxonomyTagsDataLoaded', () => {
it('should return true when status is success', () => {
useQuery.mockReturnValueOnce({ status: 'success' });
const taxonomyId = '123';
const result = useIsTaxonomyTagsDataLoaded(taxonomyId);
expect(result).toBe(true);
});
it('should return false when status is not success', () => {
useQuery.mockReturnValueOnce({ status: 'error' });
const taxonomyId = '123';
const result = useIsTaxonomyTagsDataLoaded(taxonomyId);
expect(result).toBe(false);
});
});
describe('useContentTaxonomyTagsDataResponse', () => {
it('should return data when status is success', () => {
useQuery.mockReturnValueOnce({ status: 'success', data: { data: 'data' } });
const contentId = '123';
const result = useContentTaxonomyTagsDataResponse(contentId);
expect(result).toEqual({ data: 'data' });
});
it('should return undefined when status is not success', () => {
useQuery.mockReturnValueOnce({ status: 'error' });
const contentId = '123';
const result = useContentTaxonomyTagsDataResponse(contentId);
expect(result).toBeUndefined();
});
});
describe('useIsContentTaxonomyTagsDataLoaded', () => {
it('should return true when status is success', () => {
useQuery.mockReturnValueOnce({ status: 'success' });
const contentId = '123';
const result = useIsContentTaxonomyTagsDataLoaded(contentId);
expect(result).toBe(true);
});
it('should return false when status is not success', () => {
useQuery.mockReturnValueOnce({ status: 'error' });
const contentId = '123';
const result = useIsContentTaxonomyTagsDataLoaded(contentId);
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);
});
});

View File

@@ -0,0 +1,107 @@
// @ts-check
/**
* @typedef {Object} Tag
* @property {string} value
* @property {string[]} lineage
*/
/**
* @typedef {Object} ContentTaxonomyTagData
* @property {string} name
* @property {number} taxonomy_id
* @property {boolean} editable
* @property {Tag[]} tags
*/
/**
* @typedef {Object} ContentTaxonomyTagsData
* @property {ContentTaxonomyTagData[]} taxonomies
*/
/**
* @typedef {Object} ContentActions
* @property {boolean} deleteable
* @property {boolean} draggable
* @property {boolean} childAddable
* @property {boolean} duplicable
*/
/**
* @typedef {Object} ContentData
* @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} 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
*/
/**
* @typedef {Object} UseQueryResult
* @property {Object} data
* @property {string} status
*/

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export { default as ContentTagsDrawer } from './ContentTagsDrawer';

View File

@@ -0,0 +1,26 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
headerSubtitle: {
id: 'course-authoring.content-tags-drawer.header.subtitle',
defaultMessage: 'Manage tags',
},
addTagsButtonText: {
id: 'course-authoring.content-tags-drawer.collapsible.add-tags.button',
defaultMessage: 'Add tags',
},
loadingMessage: {
id: 'course-authoring.content-tags-drawer.spinner.loading',
defaultMessage: 'Loading',
},
loadingTagsDropdownMessage: {
id: 'course-authoring.content-tags-drawer.tags-dropdown-selector.spinner.loading',
defaultMessage: 'Loading tags',
},
taxonomyTagsAriaLabel: {
id: 'course-authoring.content-tags-drawer.content-tags-collapsible.selectable-box.selection.aria.label',
defaultMessage: 'taxonomy tags selection',
},
});
export default messages;

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export const extractOrgFromContentId = (contentId) => contentId.split('+')[0].split(':')[1];

View File

@@ -23,6 +23,7 @@ import Head from './head/Head';
import { StudioHome } from './studio-home';
import CourseRerun from './course-rerun';
import { TaxonomyListPage } from './taxonomy';
import { ContentTagsDrawer } from './content-tags-drawer';
import 'react-datepicker/dist/react-datepicker.css';
import './index.scss';
@@ -53,10 +54,16 @@ const App = () => {
<Route path="/course/:courseId/*" element={<CourseAuthoringRoutes />} />
<Route path="/course_rerun/:courseId" element={<CourseRerun />} />
{process.env.ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && (
<Route
path="/taxonomy-list"
element={<TaxonomyListPage />}
/>
<>
<Route
path="/taxonomy-list"
element={<TaxonomyListPage />}
/>
<Route
path="/tagging/components/widget/:contentId"
element={<ContentTagsDrawer />}
/>
</>
)}
</Routes>
</QueryClientProvider>

View File

@@ -20,3 +20,4 @@
@import "import-page/CourseImportPage";
@import "taxonomy/taxonomy-card/TaxonomyCard";
@import "files-and-videos";
@import "content-tags-drawer/TagBubble";

View File

@@ -3,7 +3,16 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getTaxonomyListApiUrl = () => new URL('api/content_tagging/v1/taxonomies/?enabled=true', getApiBaseUrl()).href;
export const getTaxonomyListApiUrl = (org) => {
const url = new URL('api/content_tagging/v1/taxonomies/', getApiBaseUrl());
url.searchParams.append('enabled', 'true');
if (org !== undefined) {
url.searchParams.append('org', org);
}
return url.href;
};
export const getExportTaxonomyApiUrl = (pk, format) => new URL(
`api/content_tagging/v1/taxonomies/${pk}/export/?output_format=${format}&download=1`,
getApiBaseUrl(),
@@ -11,10 +20,11 @@ export const getExportTaxonomyApiUrl = (pk, format) => new URL(
/**
* Get list of taxonomies.
* @param {string} org Optioanl organization query param
* @returns {Promise<Object>}
*/
export async function getTaxonomyListData() {
const { data } = await getAuthenticatedHttpClient().get(getTaxonomyListApiUrl());
export async function getTaxonomyListData(org) {
const { data } = await getAuthenticatedHttpClient().get(getTaxonomyListApiUrl(org));
return camelCaseObject(data);
}

View File

@@ -50,6 +50,15 @@ describe('taxonomy api calls', () => {
expect(result).toEqual(taxonomyListMock);
});
it('should get taxonomy list data with org', async () => {
const org = 'testOrg';
axiosMock.onGet(getTaxonomyListApiUrl(org)).reply(200, taxonomyListMock);
const result = await getTaxonomyListData(org);
expect(axiosMock.history.get[0].url).toEqual(getTaxonomyListApiUrl(org));
expect(result).toEqual(taxonomyListMock);
});
it('should set window.location.href correctly', () => {
const pk = 1;
const format = 'json';

View File

@@ -15,22 +15,24 @@ import { useQuery } from '@tanstack/react-query';
import { getTaxonomyListData } from './api';
/**
* Builds the query yo get the taxonomy list
* Builds the query to get the taxonomy list
* @param {string} org Optional organization query param
* @returns {import("./types.mjs").UseQueryResult}
*/
const useTaxonomyListData = () => (
const useTaxonomyListData = (org) => (
useQuery({
queryKey: ['taxonomyList'],
queryFn: getTaxonomyListData,
queryFn: () => getTaxonomyListData(org),
})
);
/**
* Gets the taxonomy list data
* @param {string} org Optional organization query param
* @returns {import("./types.mjs").TaxonomyListData | undefined}
*/
export const useTaxonomyListDataResponse = () => {
const response = useTaxonomyListData();
export const useTaxonomyListDataResponse = (org) => {
const response = useTaxonomyListData(org);
if (response.status === 'success') {
return response.data;
}
@@ -39,8 +41,9 @@ export const useTaxonomyListDataResponse = () => {
/**
* Returns the status of the taxonomy list query
* @param {string} org Optional organization param
* @returns {boolean}
*/
export const useIsTaxonomyListDataLoaded = () => (
useTaxonomyListData().status === 'success'
export const useIsTaxonomyListDataLoaded = (org) => (
useTaxonomyListData(org).status === 'success'
);

View File

@@ -1,6 +1,6 @@
// @ts-check
/**
/**
* @typedef {Object} TaxonomyData
* @property {number} id
* @property {string} name