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:
committed by
Adolfo R. Brandes
parent
9b053de0b7
commit
375006deb1
117
src/content-tags-drawer/ContentTagsCollapsible.jsx
Normal file
117
src/content-tags-drawer/ContentTagsCollapsible.jsx
Normal 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;
|
||||
24
src/content-tags-drawer/ContentTagsCollapsible.scss
Normal file
24
src/content-tags-drawer/ContentTagsCollapsible.scss
Normal 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;
|
||||
}
|
||||
63
src/content-tags-drawer/ContentTagsCollapsible.test.jsx
Normal file
63
src/content-tags-drawer/ContentTagsCollapsible.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
127
src/content-tags-drawer/ContentTagsDrawer.jsx
Normal file
127
src/content-tags-drawer/ContentTagsDrawer.jsx
Normal 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;
|
||||
186
src/content-tags-drawer/ContentTagsDrawer.test.jsx
Normal file
186
src/content-tags-drawer/ContentTagsDrawer.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
95
src/content-tags-drawer/ContentTagsDropDownSelector.jsx
Normal file
95
src/content-tags-drawer/ContentTagsDropDownSelector.jsx
Normal 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;
|
||||
8
src/content-tags-drawer/ContentTagsDropDownSelector.scss
Normal file
8
src/content-tags-drawer/ContentTagsDropDownSelector.scss
Normal file
@@ -0,0 +1,8 @@
|
||||
.taxonomy-tags-arrow-drop-down {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pgn__selectable_box.taxonomy-tags-selectable-box {
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
96
src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx
Normal file
96
src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
88
src/content-tags-drawer/ContentTagsTree.jsx
Normal file
88
src/content-tags-drawer/ContentTagsTree.jsx
Normal 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;
|
||||
51
src/content-tags-drawer/ContentTagsTree.test.jsx
Normal file
51
src/content-tags-drawer/ContentTagsTree.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
42
src/content-tags-drawer/TagBubble.jsx
Normal file
42
src/content-tags-drawer/TagBubble.jsx
Normal 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;
|
||||
13
src/content-tags-drawer/TagBubble.scss
Normal file
13
src/content-tags-drawer/TagBubble.scss
Normal 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;
|
||||
}
|
||||
66
src/content-tags-drawer/TagBubble.test.jsx
Normal file
66
src/content-tags-drawer/TagBubble.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
21
src/content-tags-drawer/TagOutlineIcon.jsx
Normal file
21
src/content-tags-drawer/TagOutlineIcon.jsx
Normal 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;
|
||||
63
src/content-tags-drawer/__mocks__/contentDataMock.js
Normal file
63
src/content-tags-drawer/__mocks__/contentDataMock.js
Normal 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: '',
|
||||
},
|
||||
};
|
||||
50
src/content-tags-drawer/__mocks__/contentTaxonomyTagsMock.js
Normal file
50
src/content-tags-drawer/__mocks__/contentTaxonomyTagsMock.js
Normal 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',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
3
src/content-tags-drawer/__mocks__/index.js
Normal file
3
src/content-tags-drawer/__mocks__/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as taxonomyTagsMock } from './taxonomyTagsMock';
|
||||
export { default as contentTaxonomyTagsMock } from './contentTaxonomyTagsMock';
|
||||
export { default as contentDataMock } from './contentDataMock';
|
||||
46
src/content-tags-drawer/__mocks__/taxonomyTagsMock.js
Normal file
46
src/content-tags-drawer/__mocks__/taxonomyTagsMock.js
Normal 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',
|
||||
},
|
||||
],
|
||||
};
|
||||
42
src/content-tags-drawer/data/api.js
Normal file
42
src/content-tags-drawer/data/api.js
Normal 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);
|
||||
}
|
||||
71
src/content-tags-drawer/data/api.test.js
Normal file
71
src/content-tags-drawer/data/api.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
111
src/content-tags-drawer/data/apiHooks.jsx
Normal file
111
src/content-tags-drawer/data/apiHooks.jsx
Normal 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'
|
||||
);
|
||||
121
src/content-tags-drawer/data/apiHooks.test.jsx
Normal file
121
src/content-tags-drawer/data/apiHooks.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
107
src/content-tags-drawer/data/types.mjs
Normal file
107
src/content-tags-drawer/data/types.mjs
Normal 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
|
||||
*/
|
||||
2
src/content-tags-drawer/index.js
Normal file
2
src/content-tags-drawer/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as ContentTagsDrawer } from './ContentTagsDrawer';
|
||||
26
src/content-tags-drawer/messages.js
Normal file
26
src/content-tags-drawer/messages.js
Normal 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;
|
||||
2
src/content-tags-drawer/utils.js
Normal file
2
src/content-tags-drawer/utils.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const extractOrgFromContentId = (contentId) => contentId.split('+')[0].split(':')[1];
|
||||
@@ -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>
|
||||
|
||||
@@ -20,3 +20,4 @@
|
||||
@import "import-page/CourseImportPage";
|
||||
@import "taxonomy/taxonomy-card/TaxonomyCard";
|
||||
@import "files-and-videos";
|
||||
@import "content-tags-drawer/TagBubble";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
/**
|
||||
* @typedef {Object} TaxonomyData
|
||||
* @property {number} id
|
||||
* @property {string} name
|
||||
|
||||
Reference in New Issue
Block a user