From 92934b6b98ca521432cb093df6764acdff9d7547 Mon Sep 17 00:00:00 2001 From: Yusuf Musleh Date: Mon, 23 Oct 2023 17:18:50 +0300 Subject: [PATCH] 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 instead of loading string * docs: Add docs string to TaxonomyTagsCollapsible * feat: Use 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 --- .../ContentTagsCollapsible.jsx | 117 +++++++++++ .../ContentTagsCollapsible.scss | 24 +++ .../ContentTagsCollapsible.test.jsx | 63 ++++++ src/content-tags-drawer/ContentTagsDrawer.jsx | 127 ++++++++++++ .../ContentTagsDrawer.test.jsx | 186 ++++++++++++++++++ .../ContentTagsDropDownSelector.jsx | 95 +++++++++ .../ContentTagsDropDownSelector.scss | 8 + .../ContentTagsDropDownSelector.test.jsx | 96 +++++++++ src/content-tags-drawer/ContentTagsTree.jsx | 88 +++++++++ .../ContentTagsTree.test.jsx | 51 +++++ src/content-tags-drawer/TagBubble.jsx | 42 ++++ src/content-tags-drawer/TagBubble.scss | 13 ++ src/content-tags-drawer/TagBubble.test.jsx | 66 +++++++ src/content-tags-drawer/TagOutlineIcon.jsx | 21 ++ .../__mocks__/contentDataMock.js | 63 ++++++ .../__mocks__/contentTaxonomyTagsMock.js | 50 +++++ src/content-tags-drawer/__mocks__/index.js | 3 + .../__mocks__/taxonomyTagsMock.js | 46 +++++ src/content-tags-drawer/data/api.js | 42 ++++ src/content-tags-drawer/data/api.test.js | 71 +++++++ src/content-tags-drawer/data/apiHooks.jsx | 111 +++++++++++ .../data/apiHooks.test.jsx | 121 ++++++++++++ src/content-tags-drawer/data/types.mjs | 107 ++++++++++ src/content-tags-drawer/index.js | 2 + src/content-tags-drawer/messages.js | 26 +++ src/content-tags-drawer/utils.js | 2 + src/index.jsx | 15 +- src/index.scss | 1 + src/taxonomy/data/api.js | 16 +- src/taxonomy/data/api.test.js | 9 + src/taxonomy/data/apiHooks.jsx | 17 +- src/taxonomy/data/types.mjs | 2 +- 32 files changed, 1686 insertions(+), 15 deletions(-) create mode 100644 src/content-tags-drawer/ContentTagsCollapsible.jsx create mode 100644 src/content-tags-drawer/ContentTagsCollapsible.scss create mode 100644 src/content-tags-drawer/ContentTagsCollapsible.test.jsx create mode 100644 src/content-tags-drawer/ContentTagsDrawer.jsx create mode 100644 src/content-tags-drawer/ContentTagsDrawer.test.jsx create mode 100644 src/content-tags-drawer/ContentTagsDropDownSelector.jsx create mode 100644 src/content-tags-drawer/ContentTagsDropDownSelector.scss create mode 100644 src/content-tags-drawer/ContentTagsDropDownSelector.test.jsx create mode 100644 src/content-tags-drawer/ContentTagsTree.jsx create mode 100644 src/content-tags-drawer/ContentTagsTree.test.jsx create mode 100644 src/content-tags-drawer/TagBubble.jsx create mode 100644 src/content-tags-drawer/TagBubble.scss create mode 100644 src/content-tags-drawer/TagBubble.test.jsx create mode 100644 src/content-tags-drawer/TagOutlineIcon.jsx create mode 100644 src/content-tags-drawer/__mocks__/contentDataMock.js create mode 100644 src/content-tags-drawer/__mocks__/contentTaxonomyTagsMock.js create mode 100644 src/content-tags-drawer/__mocks__/index.js create mode 100644 src/content-tags-drawer/__mocks__/taxonomyTagsMock.js create mode 100644 src/content-tags-drawer/data/api.js create mode 100644 src/content-tags-drawer/data/api.test.js create mode 100644 src/content-tags-drawer/data/apiHooks.jsx create mode 100644 src/content-tags-drawer/data/apiHooks.test.jsx create mode 100644 src/content-tags-drawer/data/types.mjs create mode 100644 src/content-tags-drawer/index.js create mode 100644 src/content-tags-drawer/messages.js create mode 100644 src/content-tags-drawer/utils.js 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