diff --git a/README.rst b/README.rst index bc7c97066..8959458aa 100644 --- a/README.rst +++ b/README.rst @@ -250,6 +250,25 @@ Requirements * ``contentstore.new_studio_mfe.use_new_export_page``: this feature flag will change the CMS to link to the new export page. * ``contentstore.new_studio_mfe.use_new_import_page``: this feature flag will change the CMS to link to the new import page. +Feature: Tagging/Taxonomy Pages +================================ + +.. image:: ./docs/readme-images/feature-tagging-taxonomy-pages.png + +Requirements +------------ + +* ``edx-platform`` Waffle flags: + + * ``contentstore.new_studio_mfe.use_tagging_taxonomy_list_page``: this feature flag must be enabled. + +Configuration +------------- + +In additional to the standard settings, the following local configuration items are required: + +* ``ENABLE_TAGGING_TAXONOMY_PAGES``: must be enabled in order to actually present the new Tagging/Taxonomy pages. + Developing ********** diff --git a/docs/readme-images/feature-tagging-taxonomy-pages.png b/docs/readme-images/feature-tagging-taxonomy-pages.png new file mode 100644 index 000000000..e27c469c5 Binary files /dev/null and b/docs/readme-images/feature-tagging-taxonomy-pages.png differ diff --git a/src/index.scss b/src/index.scss index 3fed92570..2146be3a4 100755 --- a/src/index.scss +++ b/src/index.scss @@ -18,4 +18,5 @@ @import "course-updates/CourseUpdates"; @import "export-page/CourseExportPage"; @import "import-page/CourseImportPage"; +@import "taxonomy/taxonomy-card/TaxonomyCard"; @import "files-and-videos"; diff --git a/src/taxonomy/TaxonomyCard.jsx b/src/taxonomy/TaxonomyCard.jsx deleted file mode 100644 index 203782365..000000000 --- a/src/taxonomy/TaxonomyCard.jsx +++ /dev/null @@ -1,79 +0,0 @@ -import React from 'react'; -import { - Badge, - Card, -} from '@edx/paragon'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import messages from './messages'; -import './TaxonomyCard.scss'; - -const TaxonomyCard = ({ className, original, intl }) => { - const { - id, name, description, systemDefined, orgsCount, - } = original; - - const orgsCountEnabled = () => orgsCount !== undefined && orgsCount !== 0; - - const getHeaderSubtitle = () => { - if (systemDefined) { - return ( - - {intl.formatMessage(messages.systemDefinedBadge)} - - ); - } - if (orgsCountEnabled()) { - return ( -
- {intl.formatMessage(messages.assignedToOrgsLabel, { orgsCount })} -
- ); - } - return undefined; - }; - - const getHeaderActions = () => ( - // Menu button - // TODO Add functionality to this menu - undefined - ); - - return ( - - - - - {description} - - - - ); -}; - -TaxonomyCard.defaultProps = { - className: '', -}; - -TaxonomyCard.propTypes = { - className: PropTypes.string, - original: PropTypes.shape({ - id: PropTypes.number, - name: PropTypes.string, - description: PropTypes.string, - systemDefined: PropTypes.bool, - orgsCount: PropTypes.number, - }).isRequired, - intl: intlShape.isRequired, -}; - -export default injectIntl(TaxonomyCard); diff --git a/src/taxonomy/TaxonomyListPage.jsx b/src/taxonomy/TaxonomyListPage.jsx index 5d73ef664..98e446e45 100644 --- a/src/taxonomy/TaxonomyListPage.jsx +++ b/src/taxonomy/TaxonomyListPage.jsx @@ -5,15 +5,16 @@ import { DataTable, Spinner, } from '@edx/paragon'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { StudioFooter } from '@edx/frontend-component-footer'; +import { useIntl } from '@edx/frontend-platform/i18n'; import Header from '../header'; import SubHeader from '../generic/sub-header/SubHeader'; import messages from './messages'; -import TaxonomyCard from './TaxonomyCard'; -import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './api/hooks/selectors'; +import TaxonomyCard from './taxonomy-card'; +import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './data/apiHooks'; -const TaxonomyListPage = ({ intl }) => { +const TaxonomyListPage = () => { + const intl = useIntl(); const useTaxonomyListData = () => { const taxonomyListData = useTaxonomyListDataResponse(); const isLoaded = useIsTaxonomyListDataLoaded(); @@ -97,8 +98,6 @@ const TaxonomyListPage = ({ intl }) => { ); }; -TaxonomyListPage.propTypes = { - intl: intlShape.isRequired, -}; +TaxonomyListPage.propTypes = {}; -export default injectIntl(TaxonomyListPage); +export default TaxonomyListPage; diff --git a/src/taxonomy/TaxonomyListPage.test.jsx b/src/taxonomy/TaxonomyListPage.test.jsx index 99c947b75..8e6834756 100644 --- a/src/taxonomy/TaxonomyListPage.test.jsx +++ b/src/taxonomy/TaxonomyListPage.test.jsx @@ -7,11 +7,11 @@ import { act, render } from '@testing-library/react'; import initializeStore from '../store'; import TaxonomyListPage from './TaxonomyListPage'; -import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './api/hooks/selectors'; +import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './data/apiHooks'; let store; -jest.mock('./api/hooks/selectors', () => ({ +jest.mock('./data/apiHooks', () => ({ useTaxonomyListDataResponse: jest.fn(), useIsTaxonomyListDataLoaded: jest.fn(), })); diff --git a/src/taxonomy/__mocks__/index.js b/src/taxonomy/__mocks__/index.js new file mode 100644 index 000000000..bbbeb62a2 --- /dev/null +++ b/src/taxonomy/__mocks__/index.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export { default as taxonomyListMock } from './taxonomyListMock'; diff --git a/src/taxonomy/__mocks__/taxonomyListMock.js b/src/taxonomy/__mocks__/taxonomyListMock.js new file mode 100644 index 000000000..5eab0af02 --- /dev/null +++ b/src/taxonomy/__mocks__/taxonomyListMock.js @@ -0,0 +1,50 @@ +module.exports = { + next: null, + previous: null, + count: 4, + numPages: 1, + currentPage: 1, + start: 0, + results: [ + { + id: -2, + name: 'Content Authors', + description: 'Allows tags for any user ID created on the instance.', + enabled: true, + allowMultiple: false, + allowFreeText: false, + systemDefined: true, + visibleToAuthors: false, + }, + { + id: -1, + name: 'Languages', + description: 'lang lang lang lang lang lang lang lang', + enabled: true, + allowMultiple: false, + allowFreeText: false, + systemDefined: true, + visibleToAuthors: true, + }, + { + id: 1, + name: 'Taxonomy', + description: 'This is a Description', + enabled: true, + allowMultiple: false, + allowFreeText: false, + systemDefined: false, + visibleToAuthors: true, + }, + { + id: 2, + name: 'Taxonomy long long long long long long long long long long long long long long long long long long long', + description: 'This is a Description long lon', + enabled: true, + allowMultiple: false, + allowFreeText: false, + systemDefined: false, + visibleToAuthors: true, + }, + ], +}; diff --git a/src/taxonomy/api/hooks/api.js b/src/taxonomy/api/hooks/api.js deleted file mode 100644 index 6da57c171..000000000 --- a/src/taxonomy/api/hooks/api.js +++ /dev/null @@ -1,20 +0,0 @@ -// @ts-check -import { useQuery } from '@tanstack/react-query'; -import { camelCaseObject, getConfig } from '@edx/frontend-platform'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; - -const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; -const getTaxonomyListApiUrl = new URL('api/content_tagging/v1/taxonomies/?enabled=true', getApiBaseUrl()).href; - -/** - * @returns {import("../types.mjs").UseQueryResult} - */ -const useTaxonomyListData = () => ( - useQuery({ - queryKey: ['taxonomyList'], - queryFn: () => getAuthenticatedHttpClient().get(getTaxonomyListApiUrl) - .then(camelCaseObject), - }) -); - -export default useTaxonomyListData; diff --git a/src/taxonomy/api/hooks/api.test.js b/src/taxonomy/api/hooks/api.test.js deleted file mode 100644 index aee14b8a7..000000000 --- a/src/taxonomy/api/hooks/api.test.js +++ /dev/null @@ -1,25 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import useTaxonomyListData from './api'; - -jest.mock('@tanstack/react-query', () => ({ - useQuery: jest.fn(), -})); - -jest.mock('@edx/frontend-platform/auth', () => ({ - getAuthenticatedHttpClient: jest.fn(), -})); - -describe('taxonomy API: useTaxonomyListData', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should call useQuery with the correct parameters', () => { - useTaxonomyListData(); - - expect(useQuery).toHaveBeenCalledWith({ - queryKey: ['taxonomyList'], - queryFn: expect.any(Function), - }); - }); -}); diff --git a/src/taxonomy/api/hooks/selectors.js b/src/taxonomy/api/hooks/selectors.js deleted file mode 100644 index 6908827ca..000000000 --- a/src/taxonomy/api/hooks/selectors.js +++ /dev/null @@ -1,20 +0,0 @@ -// @ts-check -import useTaxonomyListData from './api'; - -/** - * @returns {import("../types.mjs").TaxonomyListData | undefined} - */ -export const useTaxonomyListDataResponse = () => { - const response = useTaxonomyListData(); - if (response.status === 'success') { - return response.data.data; - } - return undefined; -}; - -/** - * @returns {boolean} - */ -export const useIsTaxonomyListDataLoaded = () => ( - useTaxonomyListData().status === 'success' -); diff --git a/src/taxonomy/data/api.js b/src/taxonomy/data/api.js new file mode 100644 index 000000000..513f7fdaf --- /dev/null +++ b/src/taxonomy/data/api.js @@ -0,0 +1,29 @@ +// @ts-check +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 getExportTaxonomyApiUrl = (pk, format) => new URL( + `api/content_tagging/v1/taxonomies/${pk}/export/?output_format=${format}&download=1`, + getApiBaseUrl(), +).href; + +/** + * Get list of taxonomies. + * @returns {Promise} + */ +export async function getTaxonomyListData() { + const { data } = await getAuthenticatedHttpClient().get(getTaxonomyListApiUrl()); + return camelCaseObject(data); +} + +/** + * Downloads the file of the exported taxonomy + * @param {number} pk + * @param {string} format + * @returns {void} + */ +export function getTaxonomyExportFile(pk, format) { + window.location.href = getExportTaxonomyApiUrl(pk, format); +} diff --git a/src/taxonomy/data/api.test.js b/src/taxonomy/data/api.test.js new file mode 100644 index 000000000..5928c7009 --- /dev/null +++ b/src/taxonomy/data/api.test.js @@ -0,0 +1,61 @@ +import MockAdapter from 'axios-mock-adapter'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import { taxonomyListMock } from '../__mocks__'; + +import { + getTaxonomyListApiUrl, + getExportTaxonomyApiUrl, + getTaxonomyListData, + getTaxonomyExportFile, +} from './api'; + +let axiosMock; + +describe('taxonomy api calls', () => { + const { location } = window; + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + beforeAll(() => { + delete window.location; + window.location = { + href: '', + }; + }); + + afterAll(() => { + window.location = location; + }); + + it('should get taxonomy list data', async () => { + axiosMock.onGet(getTaxonomyListApiUrl()).reply(200, taxonomyListMock); + const result = await getTaxonomyListData(); + + expect(axiosMock.history.get[0].url).toEqual(getTaxonomyListApiUrl()); + expect(result).toEqual(taxonomyListMock); + }); + + it('should set window.location.href correctly', () => { + const pk = 1; + const format = 'json'; + + getTaxonomyExportFile(pk, format); + + expect(window.location.href).toEqual(getExportTaxonomyApiUrl(pk, format)); + }); +}); diff --git a/src/taxonomy/data/apiHooks.jsx b/src/taxonomy/data/apiHooks.jsx new file mode 100644 index 000000000..40eadf3d1 --- /dev/null +++ b/src/taxonomy/data/apiHooks.jsx @@ -0,0 +1,46 @@ +// @ts-check +/** + * This is a file used especially in this `taxonomy` module. + * + * We are using a new approach, using `useQuery` to build and execute the queries to the APIs. + * This approach accelerates the development. + * + * In this file you will find two types of hooks: + * - Hooks that builds the query with `useQuery`. These hooks are not used outside of this file. + * Ex. useTaxonomyListData. + * - Hooks that calls the query hook, prepare and return the data. + * Ex. useTaxonomyListDataResponse & useIsTaxonomyListDataLoaded. + */ +import { useQuery } from '@tanstack/react-query'; +import { getTaxonomyListData } from './api'; + +/** + * Builds the query yo get the taxonomy list + * @returns {import("./types.mjs").UseQueryResult} + */ +const useTaxonomyListData = () => ( + useQuery({ + queryKey: ['taxonomyList'], + queryFn: getTaxonomyListData, + }) +); + +/** + * Gets the taxonomy list data + * @returns {import("./types.mjs").TaxonomyListData | undefined} + */ +export const useTaxonomyListDataResponse = () => { + const response = useTaxonomyListData(); + if (response.status === 'success') { + return response.data; + } + return undefined; +}; + +/** + * Returns the status of the taxonomy list query + * @returns {boolean} + */ +export const useIsTaxonomyListDataLoaded = () => ( + useTaxonomyListData().status === 'success' +); diff --git a/src/taxonomy/api/hooks/selectors.test.js b/src/taxonomy/data/apiHooks.test.jsx similarity index 56% rename from src/taxonomy/api/hooks/selectors.test.js rename to src/taxonomy/data/apiHooks.test.jsx index b513b9b73..66715b04c 100644 --- a/src/taxonomy/api/hooks/selectors.test.js +++ b/src/taxonomy/data/apiHooks.test.jsx @@ -1,22 +1,24 @@ -import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './selectors'; -import useTaxonomyListData from './api'; +import { useQuery } from '@tanstack/react-query'; +import { + useTaxonomyListDataResponse, + useIsTaxonomyListDataLoaded, +} from './apiHooks'; -jest.mock('./api', () => ({ - __esModule: true, - default: jest.fn(), +jest.mock('@tanstack/react-query', () => ({ + useQuery: jest.fn(), })); describe('useTaxonomyListDataResponse', () => { it('should return data when status is success', () => { - useTaxonomyListData.mockReturnValueOnce({ status: 'success', data: { data: 'data' } }); + useQuery.mockReturnValueOnce({ status: 'success', data: { data: 'data' } }); const result = useTaxonomyListDataResponse(); - expect(result).toEqual('data'); + expect(result).toEqual({ data: 'data' }); }); it('should return undefined when status is not success', () => { - useTaxonomyListData.mockReturnValueOnce({ status: 'error' }); + useQuery.mockReturnValueOnce({ status: 'error' }); const result = useTaxonomyListDataResponse(); @@ -26,7 +28,7 @@ describe('useTaxonomyListDataResponse', () => { describe('useIsTaxonomyListDataLoaded', () => { it('should return true when status is success', () => { - useTaxonomyListData.mockReturnValueOnce({ status: 'success' }); + useQuery.mockReturnValueOnce({ status: 'success' }); const result = useIsTaxonomyListDataLoaded(); @@ -34,7 +36,7 @@ describe('useIsTaxonomyListDataLoaded', () => { }); it('should return false when status is not success', () => { - useTaxonomyListData.mockReturnValueOnce({ status: 'error' }); + useQuery.mockReturnValueOnce({ status: 'error' }); const result = useIsTaxonomyListDataLoaded(); diff --git a/src/taxonomy/api/types.mjs b/src/taxonomy/data/types.mjs similarity index 100% rename from src/taxonomy/api/types.mjs rename to src/taxonomy/data/types.mjs diff --git a/src/taxonomy/export-modal/index.jsx b/src/taxonomy/export-modal/index.jsx new file mode 100644 index 000000000..d380aea6e --- /dev/null +++ b/src/taxonomy/export-modal/index.jsx @@ -0,0 +1,85 @@ +import React, { useState } from 'react'; +import { + ActionRow, + Button, + Form, + ModalDialog, +} from '@edx/paragon'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import messages from './messages'; +import { getTaxonomyExportFile } from '../data/api'; + +const ExportModal = ({ + taxonomyId, + isOpen, + onClose, +}) => { + const intl = useIntl(); + const [outputFormat, setOutputFormat] = useState('csv'); + + const onClickExport = () => { + onClose(); + getTaxonomyExportFile(taxonomyId, outputFormat); + }; + + return ( + + + + {intl.formatMessage(messages.exportModalTitle)} + + + + + + {intl.formatMessage(messages.exportModalBodyDescription)} + + setOutputFormat(e.target.value)} + > + + {intl.formatMessage(messages.taxonomyCSVFormat)} + + + {intl.formatMessage(messages.taxonomyJSONFormat)} + + + + + + + + {intl.formatMessage(messages.taxonomyModalsCancelLabel)} + + + + + + ); +}; + +ExportModal.propTypes = { + taxonomyId: PropTypes.number.isRequired, + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, +}; + +export default ExportModal; diff --git a/src/taxonomy/export-modal/messages.js b/src/taxonomy/export-modal/messages.js new file mode 100644 index 000000000..5992f0557 --- /dev/null +++ b/src/taxonomy/export-modal/messages.js @@ -0,0 +1,30 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + exportModalTitle: { + id: 'course-authoring.taxonomy-list.modal.export.title', + defaultMessage: 'Select format to export', + }, + exportModalBodyDescription: { + id: 'course-authoring.taxonomy-list.modal.export.body', + defaultMessage: 'Select the file format in which you would like the taxonomy to be exported:', + }, + exportModalSubmitButtonLabel: { + id: 'course-authoring.taxonomy-list.modal.export.submit.label', + defaultMessage: 'Export', + }, + taxonomyCSVFormat: { + id: 'course-authoring.taxonomy-list.csv-format', + defaultMessage: 'CSV file', + }, + taxonomyJSONFormat: { + id: 'course-authoring.taxonomy-list.json-format', + defaultMessage: 'JSON file', + }, + taxonomyModalsCancelLabel: { + id: 'course-authoring.taxonomy-list.modal.cancel', + defaultMessage: 'Cancel', + }, +}); + +export default messages; diff --git a/src/taxonomy/messages.js b/src/taxonomy/messages.js index a15d76283..82467e09b 100644 --- a/src/taxonomy/messages.js +++ b/src/taxonomy/messages.js @@ -17,14 +17,6 @@ const messages = defineMessages({ id: 'course-authoring.taxonomy-list.select.org.default', defaultMessage: 'All taxonomies', }, - systemDefinedBadge: { - id: 'course-authoring.taxonomy-list.badge.system-defined.label', - defaultMessage: 'System-level', - }, - assignedToOrgsLabel: { - id: 'course-authoring.taxonomy-list.orgs-count.label', - defaultMessage: 'Assigned to {orgsCount} orgs', - }, usageLoadingMessage: { id: 'course-authoring.taxonomy-list.spinner.loading', defaultMessage: 'Loading', diff --git a/src/taxonomy/TaxonomyCard.scss b/src/taxonomy/taxonomy-card/TaxonomyCard.scss similarity index 60% rename from src/taxonomy/TaxonomyCard.scss rename to src/taxonomy/taxonomy-card/TaxonomyCard.scss index 35e198abe..b4839bd1c 100644 --- a/src/taxonomy/TaxonomyCard.scss +++ b/src/taxonomy/taxonomy-card/TaxonomyCard.scss @@ -29,4 +29,21 @@ text-overflow: ellipsis; white-space: nowrap; } + + .taxonomy-menu-item:focus { + /** + * There is a bug in the menu that auto focus the first item. + * We convert the focus style to a normal style. + */ + background-color: white !important; + font-weight: normal !important; + } + + .taxonomy-menu-item:focus:hover { + /** + * Check the previous block about the focus. + * This enable a normal hover to focused items. + */ + background-color: $light-500 !important; + } } diff --git a/src/taxonomy/TaxonomyCard.test.jsx b/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx similarity index 52% rename from src/taxonomy/TaxonomyCard.test.jsx rename to src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx index d4efe90bc..79f72eeb2 100644 --- a/src/taxonomy/TaxonomyCard.test.jsx +++ b/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx @@ -2,20 +2,26 @@ import React from 'react'; import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n'; import { initializeMockApp } from '@edx/frontend-platform'; import { AppProvider } from '@edx/frontend-platform/react'; -import { render } from '@testing-library/react'; +import { render, fireEvent } from '@testing-library/react'; import PropTypes from 'prop-types'; -import initializeStore from '../store'; - -import TaxonomyCard from './TaxonomyCard'; +import initializeStore from '../../store'; +import { getTaxonomyExportFile } from '../data/api'; +import TaxonomyCard from '.'; let store; +const taxonomyId = 1; const data = { + id: taxonomyId, name: 'Taxonomy 1', description: 'This is a description', }; +jest.mock('../data/api', () => ({ + getTaxonomyExportFile: jest.fn(), +})); + const TaxonomyCardComponent = ({ original }) => ( @@ -81,4 +87,59 @@ describe('', async () => { const { getByText } = render(); expect(getByText('Assigned to 6 orgs')).toBeInTheDocument(); }); + + test('should open and close menu on button click', () => { + const { getByTestId, getByText } = render(); + + // Menu closed + expect(() => getByTestId('taxonomy-card-menu-1')).toThrow(); + + // Click on the menu button to open + fireEvent.click(getByTestId('taxonomy-card-menu-button-1')); + + // Menu opened + expect(getByTestId('taxonomy-card-menu-1')).toBeInTheDocument(); + + // Click on any element to close the menu + fireEvent.click(getByText('Export')); + + // Menu closed + expect(() => getByTestId('taxonomy-card-menu-1')).toThrow(); + }); + + test('should open export modal on export menu click', () => { + const { getByTestId, getByText } = render(); + + // Modal closed + expect(() => getByText('Select format to export')).toThrow(); + + // Click on export menu + fireEvent.click(getByTestId('taxonomy-card-menu-button-1')); + fireEvent.click(getByText('Export')); + + // Modal opened + expect(getByText('Select format to export')).toBeInTheDocument(); + + // Click on cancel button + fireEvent.click(getByText('Cancel')); + + // Modal closed + expect(() => getByText('Select format to export')).toThrow(); + }); + + test('should export a taxonomy', () => { + const { getByTestId, getByText } = render(); + + // Click on export menu + fireEvent.click(getByTestId('taxonomy-card-menu-button-1')); + fireEvent.click(getByText('Export')); + + // Select JSON format and click on export + fireEvent.click(getByText('JSON file')); + fireEvent.click(getByText('Export')); + + // Modal closed + expect(() => getByText('Select format to export')).toThrow(); + expect(getTaxonomyExportFile).toHaveBeenCalledWith(taxonomyId, 'json'); + }); }); diff --git a/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx b/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx new file mode 100644 index 000000000..7f677bd4a --- /dev/null +++ b/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx @@ -0,0 +1,59 @@ +import React, { useState } from 'react'; +import { + IconButton, + ModalPopup, + Menu, + Icon, + MenuItem, +} from '@edx/paragon'; +import { MoreVert } from '@edx/paragon/icons'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import messages from './messages'; + +const TaxonomyCardMenu = ({ + id, name, onClickMenuItem, +}) => { + const intl = useIntl(); + const [menuIsOpen, setMenuIsOpen] = useState(false); + const [menuTarget, setMenuTarget] = useState(null); + + const onClickItem = (menuName) => { + setMenuIsOpen(false); + onClickMenuItem(menuName); + }; + + return ( + <> + setMenuIsOpen(true)} + ref={setMenuTarget} + src={MoreVert} + iconAs={Icon} + alt={intl.formatMessage(messages.taxonomyMenuAlt, { name })} + data-testid={`taxonomy-card-menu-button-${id}`} + /> + setMenuIsOpen(false)} + > + + {/* Add more menu items here */} + onClickItem('export')}> + {intl.formatMessage(messages.taxonomyCardExportMenu)} + + + + + ); +}; + +TaxonomyCardMenu.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + onClickMenuItem: PropTypes.func.isRequired, +}; + +export default TaxonomyCardMenu; diff --git a/src/taxonomy/taxonomy-card/index.jsx b/src/taxonomy/taxonomy-card/index.jsx new file mode 100644 index 000000000..75a8673da --- /dev/null +++ b/src/taxonomy/taxonomy-card/index.jsx @@ -0,0 +1,155 @@ +import React, { useState } from 'react'; +import { + Badge, + Card, + OverlayTrigger, + Popover, +} from '@edx/paragon'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import messages from './messages'; +import TaxonomyCardMenu from './TaxonomyCardMenu'; +import ExportModal from '../export-modal'; + +const orgsCountEnabled = (orgsCount) => orgsCount !== undefined && orgsCount !== 0; + +const HeaderSubtitle = ({ + id, showSystemBadge, orgsCount, +}) => { + const intl = useIntl(); + const getSystemToolTip = () => ( + + + {intl.formatMessage(messages.systemTaxonomyPopoverTitle)} + + + {intl.formatMessage(messages.systemTaxonomyPopoverBody)} + + + ); + + // Show system defined badge + if (showSystemBadge) { + return ( + + + {intl.formatMessage(messages.systemDefinedBadge)} + + + ); + } + + // Or show orgs count + if (orgsCountEnabled(orgsCount)) { + return ( +
+ {intl.formatMessage(messages.assignedToOrgsLabel, { orgsCount })} +
+ ); + } + + // Or none + return null; +}; + +HeaderSubtitle.propTypes = { + id: PropTypes.number.isRequired, + showSystemBadge: PropTypes.bool.isRequired, + orgsCount: PropTypes.number.isRequired, +}; + +const TaxonomyCard = ({ className, original }) => { + const { + id, name, description, systemDefined, orgsCount, + } = original; + + const intl = useIntl(); + const [isExportModalOpen, setIsExportModalOpen] = useState(false); + + // Add here more menu item actions + const menuItemActions = { + export: () => setIsExportModalOpen(true), + }; + + const onClickMenuItem = (menuName) => ( + menuItemActions[menuName]?.() + ); + + const getHeaderActions = () => { + if (systemDefined) { + // We don't show the export menu, because the system-taxonomies + // can't be exported. The API returns and error. + // The entire menu has been hidden because currently only + // the export menu exists. + // + // TODO When adding more menus, change this logic to hide only the export menu. + return undefined; + } + return ( + + ); + }; + + const renderExportModal = () => isExportModalOpen && ( + setIsExportModalOpen(false)} + taxonomyId={id} + /> + ); + + return ( + <> + + + )} + actions={getHeaderActions()} + /> + + + {description} + + + + {renderExportModal()} + + ); +}; + +TaxonomyCard.defaultProps = { + className: '', +}; + +TaxonomyCard.propTypes = { + className: PropTypes.string, + original: PropTypes.shape({ + id: PropTypes.number, + name: PropTypes.string, + description: PropTypes.string, + systemDefined: PropTypes.bool, + orgsCount: PropTypes.number, + }).isRequired, +}; + +export default TaxonomyCard; diff --git a/src/taxonomy/taxonomy-card/messages.js b/src/taxonomy/taxonomy-card/messages.js new file mode 100644 index 000000000..6886c2f99 --- /dev/null +++ b/src/taxonomy/taxonomy-card/messages.js @@ -0,0 +1,30 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + systemTaxonomyPopoverTitle: { + id: 'course-authoring.taxonomy-list.popover.system-defined.title', + defaultMessage: 'System taxonomy', + }, + systemTaxonomyPopoverBody: { + id: 'course-authoring.taxonomy-list.popover.system-defined.body', + defaultMessage: 'This is a system-level taxonomy and is enabled by default.', + }, + systemDefinedBadge: { + id: 'course-authoring.taxonomy-list.badge.system-defined.label', + defaultMessage: 'System-level', + }, + assignedToOrgsLabel: { + id: 'course-authoring.taxonomy-list.orgs-count.label', + defaultMessage: 'Assigned to {orgsCount} orgs', + }, + taxonomyCardExportMenu: { + id: 'course-authoring.taxonomy-list.menu.export.label', + defaultMessage: 'Export', + }, + taxonomyMenuAlt: { + id: 'course-authoring.taxonomy-list.menu.alt', + defaultMessage: '{name} menu', + }, +}); + +export default messages;