diff --git a/src/content-tags-drawer/ContentTagsCollapsible.jsx b/src/content-tags-drawer/ContentTagsCollapsible.jsx new file mode 100644 index 0000000000..5e27f8291c --- /dev/null +++ b/src/content-tags-drawer/ContentTagsCollapsible.jsx @@ -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 ( +
+ +
+ +
+ +
+ +
+ +
+ + + + +
+
+ +
+
+ + {contentTags.length} + +
+
+ ); +}; + +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; diff --git a/src/content-tags-drawer/ContentTagsCollapsible.scss b/src/content-tags-drawer/ContentTagsCollapsible.scss new file mode 100644 index 0000000000..a207fd9474 --- /dev/null +++ b/src/content-tags-drawer/ContentTagsCollapsible.scss @@ -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; +} diff --git a/src/content-tags-drawer/ContentTagsCollapsible.test.jsx b/src/content-tags-drawer/ContentTagsCollapsible.test.jsx new file mode 100644 index 0000000000..eca8c94bfc --- /dev/null +++ b/src/content-tags-drawer/ContentTagsCollapsible.test.jsx @@ -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 }) => ( + + + +); + +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('', () => { + it('should render taxonomy tags data along content tags number badge', async () => { + await act(async () => { + const { container, getByText } = render(); + 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(); + expect(getByText('Taxonomy 1')).toBeInTheDocument(); + expect(container.getElementsByClassName('invisible').length).toBe(1); + }); + }); +}); diff --git a/src/content-tags-drawer/ContentTagsDrawer.jsx b/src/content-tags-drawer/ContentTagsDrawer.jsx new file mode 100644 index 0000000000..eeed39be1d --- /dev/null +++ b/src/content-tags-drawer/ContentTagsDrawer.jsx @@ -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 ( + +
+ + closeContentTagsDrawer()} data-testid="drawer-close-button" /> + {intl.formatMessage(messages.headerSubtitle)} + { isContentDataLoaded + ?

{ contentData.displayName }

+ : ( +
+ +
+ )} + +
+ + { isTaxonomyListLoaded && isContentTaxonomyTagsLoaded + ? taxonomies.map((data) => ( +
+ +
+
+ )) + : } + +
+
+ ); +}; + +export default ContentTagsDrawer; diff --git a/src/content-tags-drawer/ContentTagsDrawer.test.jsx b/src/content-tags-drawer/ContentTagsDrawer.test.jsx new file mode 100644 index 0000000000..ad479b1569 --- /dev/null +++ b/src/content-tags-drawer/ContentTagsDrawer.test.jsx @@ -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 = () => ( + + + +); + +describe('', () => { + it('should render page and page title correctly', () => { + const { getByText } = render(); + 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(); + const spinner = getAllByRole('status')[0]; + expect(spinner.textContent).toEqual('Loading'); // Uses + }); + }); + + it('shows spinner before the taxonomy tags query is complete', async () => { + useIsTaxonomyListDataLoaded.mockReturnValue(false); + useIsContentTaxonomyTagsDataLoaded.mockReturnValue(false); + await act(async () => { + const { getAllByRole } = render(); + const spinner = getAllByRole('status')[1]; + expect(spinner.textContent).toEqual('Loading...'); // Uses + }); + }); + + 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(); + 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(); + 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(); + + // 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(); + + 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(); + + // 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(); + }); +}); diff --git a/src/content-tags-drawer/ContentTagsDropDownSelector.jsx b/src/content-tags-drawer/ContentTagsDropDownSelector.jsx new file mode 100644 index 0000000000..e71ac8a80f --- /dev/null +++ b/src/content-tags-drawer/ContentTagsDropDownSelector.jsx @@ -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) => ( +
+
+ + {taxonomyTag.value} + + { taxonomyTag.subTagsUrl + && ( +
+ clickAndEnterHandler(i)} + tabIndex="0" + onKeyPress={(event) => (event.key === 'Enter' ? clickAndEnterHandler(i) : null)} + /> +
+ )} +
+ + { taxonomyTag.subTagsUrl && isOpen(i) && ( + + )} + +
+ )) + : ( +
+ +
+ ) + ); +}; + +ContentTagsDropDownSelector.defaultProps = { + subTagsUrl: undefined, +}; + +ContentTagsDropDownSelector.propTypes = { + taxonomyId: PropTypes.number.isRequired, + level: PropTypes.number.isRequired, + subTagsUrl: PropTypes.string, +}; + +export default ContentTagsDropDownSelector; diff --git a/src/content-tags-drawer/ContentTagsDropDownSelector.scss b/src/content-tags-drawer/ContentTagsDropDownSelector.scss new file mode 100644 index 0000000000..33c29517e8 --- /dev/null +++ b/src/content-tags-drawer/ContentTagsDropDownSelector.scss @@ -0,0 +1,8 @@ +.taxonomy-tags-arrow-drop-down { + cursor: pointer; +} + +.pgn__selectable_box.taxonomy-tags-selectable-box { + box-shadow: none; + padding: 0; +} diff --git a/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx b/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx new file mode 100644 index 0000000000..80ca659632 --- /dev/null +++ b/src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx @@ -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, +}) => ( + + + +); + +TaxonomyTagsDropDownSelectorComponent.defaultProps = { + subTagsUrl: undefined, +}; + +TaxonomyTagsDropDownSelectorComponent.propTypes = { + taxonomyId: PropTypes.number.isRequired, + level: PropTypes.number.isRequired, + subTagsUrl: PropTypes.string, +}; + +describe('', () => { + it('should render taxonomy tags drop down selector loading with spinner', async () => { + useIsTaxonomyTagsDataLoaded.mockReturnValue(false); + await act(async () => { + const { getByRole } = render( + , + ); + const spinner = getByRole('status'); + expect(spinner.textContent).toEqual('Loading tags'); // Uses + }); + }); + + 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( + , + ); + 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( + , + ); + expect(getByText('Tag 2')).toBeInTheDocument(); + expect(container.getElementsByClassName('taxonomy-tags-arrow-drop-down').length).toBe(1); + }); + }); +}); diff --git a/src/content-tags-drawer/ContentTagsTree.jsx b/src/content-tags-drawer/ContentTagsTree.jsx new file mode 100644 index 0000000000..e75ead4766 --- /dev/null +++ b/src/content-tags-drawer/ContentTagsTree.jsx @@ -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 ( +
+ + { renderTagsTree(tag[key], level + 1) } +
+ ); + } + 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; diff --git a/src/content-tags-drawer/ContentTagsTree.test.jsx b/src/content-tags-drawer/ContentTagsTree.test.jsx new file mode 100644 index 0000000000..dd28cc9a98 --- /dev/null +++ b/src/content-tags-drawer/ContentTagsTree.test.jsx @@ -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 }) => ( + + + +); + +ContentTagsTreeComponent.propTypes = { + appliedContentTags: PropTypes.arrayOf(PropTypes.shape({ + value: PropTypes.string, + lineage: PropTypes.arrayOf(PropTypes.string), + })).isRequired, +}; + +describe('', () => { + it('should render taxonomy tags data along content tags number badge', async () => { + await act(async () => { + const { getByText } = render(); + 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(); + }); + }); +}); diff --git a/src/content-tags-drawer/TagBubble.jsx b/src/content-tags-drawer/TagBubble.jsx new file mode 100644 index 0000000000..8c7137ffa0 --- /dev/null +++ b/src/content-tags-drawer/TagBubble.jsx @@ -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 ? : ); + return ( +
+ +
+ ); +}; + +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; diff --git a/src/content-tags-drawer/TagBubble.scss b/src/content-tags-drawer/TagBubble.scss new file mode 100644 index 0000000000..281d0fe209 --- /dev/null +++ b/src/content-tags-drawer/TagBubble.scss @@ -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; +} diff --git a/src/content-tags-drawer/TagBubble.test.jsx b/src/content-tags-drawer/TagBubble.test.jsx new file mode 100644 index 0000000000..90ba32f288 --- /dev/null +++ b/src/content-tags-drawer/TagBubble.test.jsx @@ -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 }) => ( + + + +); + +TagBubbleComponent.defaultProps = { + subTagsCount: 0, + implicit: true, +}; + +TagBubbleComponent.propTypes = { + value: PropTypes.string.isRequired, + subTagsCount: PropTypes.number, + implicit: PropTypes.bool, +}; + +describe('', () => { + it('should render only value of the implicit tag with no sub tags', () => { + const { container, getByText } = render(); + 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( + , + ); + 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( + , + ); + expect(getByText(`${tagBubbleData.value}`)).toBeInTheDocument(); + expect(container.getElementsByClassName('implicit').length).toBe(0); + expect(container.getElementsByClassName('btn-icon-after').length).toBe(1); + }); +}); diff --git a/src/content-tags-drawer/TagOutlineIcon.jsx b/src/content-tags-drawer/TagOutlineIcon.jsx new file mode 100644 index 0000000000..f817b1f077 --- /dev/null +++ b/src/content-tags-drawer/TagOutlineIcon.jsx @@ -0,0 +1,21 @@ +const TagOutlineIcon = (props) => ( + +); + +export default TagOutlineIcon; diff --git a/src/content-tags-drawer/__mocks__/contentDataMock.js b/src/content-tags-drawer/__mocks__/contentDataMock.js new file mode 100644 index 0000000000..292efc38d0 --- /dev/null +++ b/src/content-tags-drawer/__mocks__/contentDataMock.js @@ -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: '', + }, +}; diff --git a/src/content-tags-drawer/__mocks__/contentTaxonomyTagsMock.js b/src/content-tags-drawer/__mocks__/contentTaxonomyTagsMock.js new file mode 100644 index 0000000000..2e8aa0bea7 --- /dev/null +++ b/src/content-tags-drawer/__mocks__/contentTaxonomyTagsMock.js @@ -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', + ], + }, + ], + }, + ], + }, +}; diff --git a/src/content-tags-drawer/__mocks__/index.js b/src/content-tags-drawer/__mocks__/index.js new file mode 100644 index 0000000000..b09fc5d3ab --- /dev/null +++ b/src/content-tags-drawer/__mocks__/index.js @@ -0,0 +1,3 @@ +export { default as taxonomyTagsMock } from './taxonomyTagsMock'; +export { default as contentTaxonomyTagsMock } from './contentTaxonomyTagsMock'; +export { default as contentDataMock } from './contentDataMock'; diff --git a/src/content-tags-drawer/__mocks__/taxonomyTagsMock.js b/src/content-tags-drawer/__mocks__/taxonomyTagsMock.js new file mode 100644 index 0000000000..0b2bc714c8 --- /dev/null +++ b/src/content-tags-drawer/__mocks__/taxonomyTagsMock.js @@ -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', + }, + ], +}; diff --git a/src/content-tags-drawer/data/api.js b/src/content-tags-drawer/data/api.js new file mode 100644 index 0000000000..e63b3d0842 --- /dev/null +++ b/src/content-tags-drawer/data/api.js @@ -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} + */ +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} + */ +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} + */ +export async function getContentData(contentId) { + const { data } = await getAuthenticatedHttpClient().get(getContentDataApiUrl(contentId)); + return camelCaseObject(data); +} diff --git a/src/content-tags-drawer/data/api.test.js b/src/content-tags-drawer/data/api.test.js new file mode 100644 index 0000000000..ffe19ab960 --- /dev/null +++ b/src/content-tags-drawer/data/api.test.js @@ -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); + }); +}); diff --git a/src/content-tags-drawer/data/apiHooks.jsx b/src/content-tags-drawer/data/apiHooks.jsx new file mode 100644 index 0000000000..099c88129d --- /dev/null +++ b/src/content-tags-drawer/data/apiHooks.jsx @@ -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' +); diff --git a/src/content-tags-drawer/data/apiHooks.test.jsx b/src/content-tags-drawer/data/apiHooks.test.jsx new file mode 100644 index 0000000000..f969782adc --- /dev/null +++ b/src/content-tags-drawer/data/apiHooks.test.jsx @@ -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); + }); +}); diff --git a/src/content-tags-drawer/data/types.mjs b/src/content-tags-drawer/data/types.mjs new file mode 100644 index 0000000000..00b3fefd4c --- /dev/null +++ b/src/content-tags-drawer/data/types.mjs @@ -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 + */ diff --git a/src/content-tags-drawer/index.js b/src/content-tags-drawer/index.js new file mode 100644 index 0000000000..8b9a48e354 --- /dev/null +++ b/src/content-tags-drawer/index.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export { default as ContentTagsDrawer } from './ContentTagsDrawer'; diff --git a/src/content-tags-drawer/messages.js b/src/content-tags-drawer/messages.js new file mode 100644 index 0000000000..6203bb2e83 --- /dev/null +++ b/src/content-tags-drawer/messages.js @@ -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; diff --git a/src/content-tags-drawer/utils.js b/src/content-tags-drawer/utils.js new file mode 100644 index 0000000000..ac81f8dc48 --- /dev/null +++ b/src/content-tags-drawer/utils.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export const extractOrgFromContentId = (contentId) => contentId.split('+')[0].split(':')[1]; diff --git a/src/index.jsx b/src/index.jsx index 0a8dfae4db..b21a502017 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -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 = () => { } /> } /> {process.env.ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && ( - } - /> + <> + } + /> + } + /> + )} diff --git a/src/index.scss b/src/index.scss index 2146be3a47..0ec41655bb 100755 --- a/src/index.scss +++ b/src/index.scss @@ -20,3 +20,4 @@ @import "import-page/CourseImportPage"; @import "taxonomy/taxonomy-card/TaxonomyCard"; @import "files-and-videos"; +@import "content-tags-drawer/TagBubble"; diff --git a/src/taxonomy/data/api.js b/src/taxonomy/data/api.js index 513f7fdaf9..94dd0af7c6 100644 --- a/src/taxonomy/data/api.js +++ b/src/taxonomy/data/api.js @@ -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} */ -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); } diff --git a/src/taxonomy/data/api.test.js b/src/taxonomy/data/api.test.js index 5928c70098..e5a01d17f4 100644 --- a/src/taxonomy/data/api.test.js +++ b/src/taxonomy/data/api.test.js @@ -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'; diff --git a/src/taxonomy/data/apiHooks.jsx b/src/taxonomy/data/apiHooks.jsx index 40eadf3d1d..fcdc9ad373 100644 --- a/src/taxonomy/data/apiHooks.jsx +++ b/src/taxonomy/data/apiHooks.jsx @@ -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' ); diff --git a/src/taxonomy/data/types.mjs b/src/taxonomy/data/types.mjs index 980939b255..7c7f4c0677 100644 --- a/src/taxonomy/data/types.mjs +++ b/src/taxonomy/data/types.mjs @@ -1,6 +1,6 @@ // @ts-check -/** +/** * @typedef {Object} TaxonomyData * @property {number} id * @property {string} name