diff --git a/src/taxonomy/TaxonomyListPage.jsx b/src/taxonomy/TaxonomyListPage.jsx index 76b9593102..4e77b12641 100644 --- a/src/taxonomy/TaxonomyListPage.jsx +++ b/src/taxonomy/TaxonomyListPage.jsx @@ -17,6 +17,7 @@ import { Check, } from '@edx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; + import { Helmet } from 'react-helmet'; import { useOrganizationListData } from '../generic/data/apiHooks'; diff --git a/src/taxonomy/TaxonomyListPage.test.jsx b/src/taxonomy/TaxonomyListPage.test.jsx index 34f55f68f3..00a960543d 100644 --- a/src/taxonomy/TaxonomyListPage.test.jsx +++ b/src/taxonomy/TaxonomyListPage.test.jsx @@ -47,9 +47,7 @@ const RootWrapper = () => ( - - - + @@ -71,6 +69,10 @@ describe('', () => { axiosMock.onGet(organizationsListUrl).reply(200, organizations); }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('should render page and page title correctly', () => { const { getByText } = render(); expect(getByText('Taxonomies')).toBeInTheDocument(); @@ -134,7 +136,11 @@ describe('', () => { it('should show all "All taxonomies", "Unassigned" and org names in taxonomy org filter', async () => { useIsTaxonomyListDataLoaded.mockReturnValue(true); useTaxonomyListDataResponse.mockReturnValue({ - results: taxonomies, + results: [{ + id: 1, + name: 'Taxonomy', + description: 'This is a description', + }], }); const { diff --git a/src/taxonomy/data/api.js b/src/taxonomy/data/api.js index be3c276e16..3c28b40bc4 100644 --- a/src/taxonomy/data/api.js +++ b/src/taxonomy/data/api.js @@ -22,10 +22,17 @@ export const getExportTaxonomyApiUrl = (pk, format) => new URL( `api/content_tagging/v1/taxonomies/${pk}/export/?output_format=${format}&download=1`, getApiBaseUrl(), ).href; + export const getTaxonomyTemplateApiUrl = (format) => new URL( `api/content_tagging/v1/taxonomies/import/template.${format}`, getApiBaseUrl(), ).href; + +/** + * Get the URL for a Taxonomy + * @param {number} pk + * @returns {string} + */ export const getTaxonomyApiUrl = (pk) => new URL(`api/content_tagging/v1/taxonomies/${pk}/`, getApiBaseUrl()).href; /** @@ -47,6 +54,15 @@ export async function deleteTaxonomy(pk) { await getAuthenticatedHttpClient().delete(getTaxonomyApiUrl(pk)); } +/** Get a Taxonomy + * @param {number} pk + * @returns {Promise} + */ +export async function getTaxonomy(pk) { + const { data } = await getAuthenticatedHttpClient().get(getTaxonomyApiUrl(pk)); + return camelCaseObject(data); +} + /** * Downloads the file of the exported taxonomy * @param {number} pk diff --git a/src/taxonomy/data/api.test.js b/src/taxonomy/data/api.test.js index dc277db8d7..50ddfe55f7 100644 --- a/src/taxonomy/data/api.test.js +++ b/src/taxonomy/data/api.test.js @@ -10,6 +10,7 @@ import { getTaxonomyListApiUrl, getTaxonomyListData, getTaxonomyApiUrl, + getTaxonomy, deleteTaxonomy, } from './api'; @@ -65,6 +66,13 @@ describe('taxonomy api calls', () => { expect(axiosMock.history.delete[0].url).toEqual(getTaxonomyApiUrl()); }); + it('should call get taxonomy', async () => { + axiosMock.onGet(getTaxonomyApiUrl(1)).reply(200); + await getTaxonomy(1); + + expect(axiosMock.history.get[0].url).toEqual(getTaxonomyApiUrl(1)); + }); + it('Export 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 eea6d5d9f4..febacf43bd 100644 --- a/src/taxonomy/data/apiHooks.jsx +++ b/src/taxonomy/data/apiHooks.jsx @@ -12,7 +12,7 @@ * Ex. useTaxonomyListDataResponse & useIsTaxonomyListDataLoaded. */ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { getTaxonomyListData, deleteTaxonomy } from './api'; +import { getTaxonomyListData, deleteTaxonomy, getTaxonomy } from './api'; /** * Builds the query to get the taxonomy list @@ -41,6 +41,16 @@ export const useDeleteTaxonomy = () => { return mutate; }; +/** Builds the query to get the taxonomy detail + * @param {number} taxonomyId + */ +const useTaxonomyDetailData = (taxonomyId) => ( + useQuery({ + queryKey: ['taxonomyDetail', taxonomyId], + queryFn: async () => getTaxonomy(taxonomyId), + }) +); + /** * Gets the taxonomy list data * @param {string} org Optional organization query param @@ -62,3 +72,35 @@ export const useTaxonomyListDataResponse = (org) => { export const useIsTaxonomyListDataLoaded = (org) => ( useTaxonomyListData(org).status === 'success' ); + +/** + * @param {number} taxonomyId + * @returns {Pick} + */ +export const useTaxonomyDetailDataStatus = (taxonomyId) => { + const { + isError, + error, + isFetched, + isSuccess, + } = useTaxonomyDetailData(taxonomyId); + return { + isError, + error, + isFetched, + isSuccess, + }; +}; + +/** + * @param {number} taxonomyId + * @returns {import("./types.mjs").TaxonomyData | undefined} + */ +export const useTaxonomyDetailDataResponse = (taxonomyId) => { + const { isSuccess, data } = useTaxonomyDetailData(taxonomyId); + if (isSuccess) { + return data; + } + + return undefined; +}; diff --git a/src/taxonomy/data/apiHooks.test.jsx b/src/taxonomy/data/apiHooks.test.jsx index e0160ad8d7..b5ec263049 100644 --- a/src/taxonomy/data/apiHooks.test.jsx +++ b/src/taxonomy/data/apiHooks.test.jsx @@ -1,5 +1,6 @@ import { useQuery, useMutation } from '@tanstack/react-query'; import { act } from '@testing-library/react'; + import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded, diff --git a/src/taxonomy/data/types.mjs b/src/taxonomy/data/types.mjs index b892592a5e..60437e7c33 100644 --- a/src/taxonomy/data/types.mjs +++ b/src/taxonomy/data/types.mjs @@ -12,6 +12,7 @@ * @property {boolean} visibleToAuthors * @property {number} tagsCount * @property {string[]} orgs + * @property {boolean} allOrgs */ /** diff --git a/src/taxonomy/import-tags/data/utils.test.js b/src/taxonomy/import-tags/data/utils.test.js new file mode 100644 index 0000000000..ddcc029410 --- /dev/null +++ b/src/taxonomy/import-tags/data/utils.test.js @@ -0,0 +1,301 @@ +import { importTaxonomy, importTaxonomyTags } from './utils'; +import { importNewTaxonomy, importTags } from './api'; + +const mockAddEventListener = jest.fn(); + +const intl = { + formatMessage: jest.fn().mockImplementation((message) => message.defaultMessage), +}; + +jest.mock('./api', () => ({ + importNewTaxonomy: jest.fn().mockResolvedValue({}), + importTags: jest.fn().mockResolvedValue({}), +})); + +describe('import new taxonomy functions', () => { + let createElement; + let appendChild; + let removeChild; + + beforeEach(() => { + createElement = document.createElement; + document.createElement = jest.fn().mockImplementation((element) => { + if (element === 'input') { + return { + click: jest.fn(), + addEventListener: mockAddEventListener, + style: {}, + }; + } + return createElement(element); + }); + + appendChild = document.body.appendChild; + document.body.appendChild = jest.fn(); + + removeChild = document.body.removeChild; + document.body.removeChild = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + document.createElement = createElement; + document.body.appendChild = appendChild; + document.body.removeChild = removeChild; + }); + + describe('import new taxonomy', () => { + it('should call the api and show success alert', async () => { + jest.spyOn(window, 'prompt') + .mockReturnValueOnce('test taxonomy name') + .mockReturnValueOnce('test taxonomy description'); + jest.spyOn(window, 'alert').mockImplementation(() => {}); + + const promise = importTaxonomy(intl).then(() => { + expect(importNewTaxonomy).toHaveBeenCalledWith('test taxonomy name', 'test taxonomy description', 'mockFile'); + expect(window.alert).toHaveBeenCalledWith('Taxonomy imported successfully'); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [ + 'mockFile', + ], + }, + }; + + onChange(mockTarget); + + return promise; + }); + + it('should ask for taxonomy name again if not provided', async () => { + jest.spyOn(window, 'prompt') + .mockReturnValueOnce('') + .mockReturnValueOnce('test taxonomy name') + .mockReturnValueOnce('test taxonomy description'); + jest.spyOn(window, 'alert').mockImplementation(() => {}); + + const promise = importTaxonomy(intl).then(() => { + expect(importNewTaxonomy).toHaveBeenCalledWith('test taxonomy name', 'test taxonomy description', 'mockFile'); + expect(window.alert).toHaveBeenCalledWith('You must enter a name for the new taxonomy'); + expect(window.alert).toHaveBeenCalledWith('Taxonomy imported successfully'); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [ + 'mockFile', + ], + }, + }; + + onChange(mockTarget); + + return promise; + }); + + it('should call the api and return error alert', async () => { + jest.spyOn(window, 'prompt') + .mockReturnValueOnce('test taxonomy name') + .mockReturnValueOnce('test taxonomy description'); + importNewTaxonomy.mockRejectedValue(new Error('test error')); + + const promise = importTaxonomy(intl).then(() => { + expect(importNewTaxonomy).toHaveBeenCalledWith('test taxonomy name', 'test taxonomy description', 'mockFile'); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [ + 'mockFile', + ], + }, + }; + + onChange(mockTarget); + + return promise; + }); + + it('should abort the call to the api without file', async () => { + const promise = importTaxonomy(intl).then(() => { + expect(importNewTaxonomy).not.toHaveBeenCalled(); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [null], + }, + }; + + onChange(mockTarget); + return promise; + }); + + it('should abort the call to the api if file closed', async () => { + const promise = importTaxonomy(intl).then(() => { + expect(importNewTaxonomy).not.toHaveBeenCalled(); + }); + + // Capture the onCancel handler from the file input element + const onCancel = mockAddEventListener.mock.calls[1][1]; + + onCancel(); + return promise; + }); + + it('should abort the call to the api when cancel name prompt', async () => { + jest.spyOn(window, 'prompt').mockReturnValueOnce(null); + + const promise = importTaxonomy(intl).then(() => { + expect(importNewTaxonomy).not.toHaveBeenCalled(); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [ + 'mockFile', + ], + }, + }; + + onChange(mockTarget); + + return promise; + }); + + it('should abort the call to the api when cancel description prompt', async () => { + jest.spyOn(window, 'prompt') + .mockReturnValueOnce('test taxonomy name') + .mockReturnValueOnce(null); + + const promise = importTaxonomy(intl).then(() => { + expect(importNewTaxonomy).not.toHaveBeenCalled(); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [ + 'mockFile', + ], + }, + }; + + onChange(mockTarget); + return promise; + }); + }); + + describe('import tags', () => { + it('should call the api and show success alert', async () => { + jest.spyOn(window, 'confirm').mockReturnValueOnce(true); + jest.spyOn(window, 'alert').mockImplementation(() => {}); + + const promise = importTaxonomyTags(1, intl).then(() => { + expect(importTags).toHaveBeenCalledWith(1, 'mockFile'); + expect(window.alert).toHaveBeenCalledWith('Taxonomy imported successfully'); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [ + 'mockFile', + ], + }, + }; + + onChange(mockTarget); + + return promise; + }); + + it('should abort the call to the api without file', async () => { + const promise = importTaxonomyTags(1, intl).then(() => { + expect(importTags).not.toHaveBeenCalled(); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [null], + }, + }; + + onChange(mockTarget); + return promise; + }); + + it('should abort the call to the api if file closed', async () => { + const promise = importTaxonomyTags(1, intl).then(() => { + expect(importTags).not.toHaveBeenCalled(); + }); + + // Capture the onCancel handler from the file input element + const onCancel = mockAddEventListener.mock.calls[1][1]; + + onCancel(); + return promise; + }); + + it('should abort the call to the api when cancel the confirm dialog', async () => { + jest.spyOn(window, 'confirm').mockReturnValueOnce(null); + + const promise = importTaxonomyTags(1, intl).then(() => { + expect(importTags).not.toHaveBeenCalled(); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [ + 'mockFile', + ], + }, + }; + + onChange(mockTarget); + + return promise; + }); + + it('should call the api and return error alert', async () => { + jest.spyOn(window, 'confirm').mockReturnValueOnce(true); + importTags.mockRejectedValue(new Error('test error')); + + const promise = importTaxonomyTags(1, intl).then(() => { + expect(importTags).toHaveBeenCalledWith(1, 'mockFile'); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [ + 'mockFile', + ], + }, + }; + + onChange(mockTarget); + + return promise; + }); + }); +}); diff --git a/src/taxonomy/manage-orgs/ManageOrgsModal.jsx b/src/taxonomy/manage-orgs/ManageOrgsModal.jsx new file mode 100644 index 0000000000..faa799b01a --- /dev/null +++ b/src/taxonomy/manage-orgs/ManageOrgsModal.jsx @@ -0,0 +1,239 @@ +// @ts-check +import React, { useContext, useEffect, useState } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + useToggle, + ActionRow, + AlertModal, + Button, + Chip, + Container, + Form, + ModalDialog, + Stack, +} from '@edx/paragon'; +import { + Close, + Warning, +} from '@edx/paragon/icons'; +import PropTypes from 'prop-types'; + +import { useOrganizationListData } from '../../generic/data/apiHooks'; +import { TaxonomyContext } from '../common/context'; +import { useTaxonomyDetailDataResponse } from '../data/apiHooks'; +import { useManageOrgs } from './data/api'; +import messages from './messages'; +import './ManageOrgsModal.scss'; + +const ConfirmModal = ({ + isOpen, + onClose, + confirm, + taxonomyName, +}) => { + const intl = useIntl(); + return ( + + + + + )} + > +

+ {intl.formatMessage(messages.confirmUnassignText, { taxonomyName })} +

+
+ ); +}; + +ConfirmModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + confirm: PropTypes.func.isRequired, + taxonomyName: PropTypes.string.isRequired, +}; + +const ManageOrgsModal = ({ + taxonomyId, + isOpen, + onClose, +}) => { + const intl = useIntl(); + const { setToastMessage } = useContext(TaxonomyContext); + + const [selectedOrgs, setSelectedOrgs] = useState(/** @type {null|string[]} */(null)); + const [allOrgs, setAllOrgs] = useState(/** @type {null|boolean} */(null)); + + const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false); + + const [isDialogDisabled, disableDialog, enableDialog] = useToggle(false); + + const { + data: organizationListData, + } = useOrganizationListData(); + + const taxonomy = useTaxonomyDetailDataResponse(taxonomyId); + + const manageOrgMutation = useManageOrgs(); + + const saveOrgs = async () => { + disableDialog(); + closeConfirmModal(); + if (selectedOrgs !== null && allOrgs !== null) { + try { + await manageOrgMutation.mutateAsync({ + taxonomyId, + orgs: allOrgs ? undefined : selectedOrgs, + allOrgs, + }); + if (setToastMessage) { + setToastMessage(intl.formatMessage(messages.assignOrgsSuccess)); + } + } catch (error) { + // ToDo: display the error to the user + } finally { + enableDialog(); + onClose(); + } + } + }; + + const confirmSave = async () => { + if (!selectedOrgs?.length && !allOrgs) { + openConfirmModal(); + } else { + await saveOrgs(); + } + }; + + useEffect(() => { + if (taxonomy) { + if (selectedOrgs === null) { + setSelectedOrgs([...taxonomy.orgs]); + } + if (allOrgs === null) { + setAllOrgs(taxonomy.allOrgs); + } + } + }, [taxonomy]); + + useEffect(() => { + if (selectedOrgs) { + // This is a hack to force the Form.Autosuggest to clear its value after a selection is made. + const inputRef = /** @type {null|HTMLInputElement} */ (document.querySelector('.manage-orgs .pgn__form-group input')); + if (inputRef) { + // @ts-ignore value can be null + inputRef.value = null; + const event = new Event('change', { bubbles: true }); + inputRef.dispatchEvent(event); + } + } + }, [selectedOrgs]); + + if (!selectedOrgs || !taxonomy) { + return null; + } + + return ( + e.stopPropagation() /* This prevents calling onClick handler from the parent */}> + + {isDialogDisabled && ( + // This div is used to prevent the user from interacting with the dialog while it is disabled +
+ )} + + + + {intl.formatMessage(messages.headerTitle)} + + + +
+ + + + +
{intl.formatMessage(messages.bodyText)}
+
{intl.formatMessage(messages.currentAssignments)}
+
+ {selectedOrgs.length ? selectedOrgs.map((org) => ( + setSelectedOrgs(selectedOrgs.filter((o) => o !== org))} + disabled={allOrgs} + > + {org} + + )) : {intl.formatMessage(messages.noOrganizationAssigned)} } +
+
+
+ + + {intl.formatMessage(messages.addOrganizations)} + + setSelectedOrgs([...selectedOrgs, org])} + disabled={allOrgs} + > + {organizationListData ? organizationListData.filter(o => !selectedOrgs?.includes(o)).map((org) => ( + {org} + )) : [] } + + + setAllOrgs(e.target.checked)}> + {intl.formatMessage(messages.assignAll)} + +
+ +
+ + + + + {intl.formatMessage(messages.cancelButton)} + + + + + + + + ); +}; + +ManageOrgsModal.propTypes = { + taxonomyId: PropTypes.number.isRequired, + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, +}; + +export default ManageOrgsModal; diff --git a/src/taxonomy/manage-orgs/ManageOrgsModal.scss b/src/taxonomy/manage-orgs/ManageOrgsModal.scss new file mode 100644 index 0000000000..4ae418ece0 --- /dev/null +++ b/src/taxonomy/manage-orgs/ManageOrgsModal.scss @@ -0,0 +1,13 @@ +.manage-orgs { + /* + This style is needed to override the default overflow: scroll on the modal, + preventing the dropdown to overflow the modal. + This is being fixed here: + https://github.com/openedx/paragon/pull/2939 + */ + overflow: visible !important; + + .pgn__modal-body { + overflow: visible; + } +} diff --git a/src/taxonomy/manage-orgs/ManageOrgsModal.test.jsx b/src/taxonomy/manage-orgs/ManageOrgsModal.test.jsx new file mode 100644 index 0000000000..01f7a7bf77 --- /dev/null +++ b/src/taxonomy/manage-orgs/ManageOrgsModal.test.jsx @@ -0,0 +1,208 @@ +import React from 'react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + fireEvent, + render, + waitFor, +} from '@testing-library/react'; +import PropTypes from 'prop-types'; + +import initializeStore from '../../store'; +import { TaxonomyContext } from '../common/context'; +import ManageOrgsModal from './ManageOrgsModal'; + +let store; + +const taxonomy = { + id: 1, + name: 'Test Taxonomy', + allOrgs: false, + orgs: ['org1', 'org2'], +}; + +const orgs = ['org1', 'org2', 'org3', 'org4', 'org5']; + +jest.mock('../data/api', () => ({ + ...jest.requireActual('../data/api'), + getTaxonomy: jest.fn().mockResolvedValue(taxonomy), +})); + +jest.mock('../../generic/data/api', () => ({ + ...jest.requireActual('../../generic/data/api'), + getOrganizations: jest.fn().mockResolvedValue(orgs), +})); + +const mockUseManageOrgsMutate = jest.fn(); + +jest.mock('./data/api', () => ({ + ...jest.requireActual('./data/api'), + useManageOrgs: jest.fn(() => ({ + ...jest.requireActual('./data/api').useManageOrgs(), + mutateAsync: mockUseManageOrgsMutate, + })), +})); + +const mockSetToastMessage = jest.fn(); +const mockSetAlertProps = jest.fn(); +const context = { + toastMessage: null, + setToastMessage: mockSetToastMessage, + alertProps: null, + setAlertProps: mockSetAlertProps, +}; + +const queryClient = new QueryClient(); + +const RootWrapper = ({ onClose }) => ( + + + + + + + + + +); + +RootWrapper.propTypes = { + onClose: PropTypes.func.isRequired, +}; + +describe('', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + }); + + afterEach(() => { + jest.clearAllMocks(); + queryClient.clear(); + }); + + const checkDialogRender = async (getByText) => { + await waitFor(() => { + // Dialog title + expect(getByText('Assign to organizations')).toBeInTheDocument(); + // Orgs assigned to the taxonomy + expect(getByText('org1')).toBeInTheDocument(); + expect(getByText('org2')).toBeInTheDocument(); + }); + }; + + it('should render the dialog and close on cancel', async () => { + const onClose = jest.fn(); + const { getByText, getByRole } = render(); + + await checkDialogRender(getByText); + + const cancelButton = getByRole('button', { name: 'Cancel' }); + fireEvent.click(cancelButton); + + expect(onClose).toHaveBeenCalled(); + }); + + it('can assign orgs to taxonomies from the dialog', async () => { + const onClose = jest.fn(); + const { + queryAllByTestId, + getByTestId, + getByText, + } = render(); + + await checkDialogRender(getByText); + + // Remove org2 + fireEvent.click(getByText('org2').nextSibling); + + const input = getByTestId('autosuggest-iconbutton'); + fireEvent.click(input); + + const list = queryAllByTestId('autosuggest-optionitem'); + expect(list.length).toBe(4); // Show org3, org4, org5 + expect(getByText('org2')).toBeInTheDocument(); + expect(getByText('org3')).toBeInTheDocument(); + expect(getByText('org4')).toBeInTheDocument(); + expect(getByText('org5')).toBeInTheDocument(); + + // Select org3 + fireEvent.click(list[1]); + + fireEvent.click(getByTestId('save-button')); + + await waitFor(() => { + expect(mockUseManageOrgsMutate).toHaveBeenCalledWith({ + taxonomyId: taxonomy.id, + orgs: ['org1', 'org3'], + allOrgs: false, + }); + }); + + // Toast message shown + expect(mockSetToastMessage).toBeCalledWith('Assigned organizations updated'); + }); + + it('can assign all orgs to taxonomies from the dialog', async () => { + const onClose = jest.fn(); + const { getByRole, getByTestId, getByText } = render(); + + await checkDialogRender(getByText); + + const checkbox = getByRole('checkbox', { name: 'Assign to all organizations' }); + fireEvent.click(checkbox); + + fireEvent.click(getByTestId('save-button')); + + await waitFor(() => { + expect(mockUseManageOrgsMutate).toHaveBeenCalledWith({ + taxonomyId: taxonomy.id, + allOrgs: true, + }); + }); + + // Toast message shown + expect(mockSetToastMessage).toBeCalledWith('Assigned organizations updated'); + }); + + it('can assign no orgs to taxonomies from the dialog', async () => { + const onClose = jest.fn(); + const { getByRole, getByTestId, getByText } = render(); + + await checkDialogRender(getByText); + + // Remove org1 + fireEvent.click(getByText('org1').nextSibling); + // Remove org2 + fireEvent.click(getByText('org2').nextSibling); + + fireEvent.click(getByTestId('save-button')); + + await waitFor(() => { + // Check confirm modal is open + expect(getByText('Unassign taxonomy')).toBeInTheDocument(); + }); + + fireEvent.click(getByRole('button', { name: 'Continue' })); + + await waitFor(() => { + expect(mockUseManageOrgsMutate).toHaveBeenCalledWith({ + taxonomyId: taxonomy.id, + allOrgs: false, + orgs: [], + }); + }); + + // Toast message shown + expect(mockSetToastMessage).toBeCalledWith('Assigned organizations updated'); + }); +}); diff --git a/src/taxonomy/manage-orgs/data/api.js b/src/taxonomy/manage-orgs/data/api.js new file mode 100644 index 0000000000..499aa3c731 --- /dev/null +++ b/src/taxonomy/manage-orgs/data/api.js @@ -0,0 +1,54 @@ +// @ts-check +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { useQueryClient, useMutation } from '@tanstack/react-query'; + +const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; + +/** + * @param {number} taxonomyId + * @returns {string} + */ +export const getManageOrgsApiUrl = (taxonomyId) => new URL( + `api/content_tagging/v1/taxonomies/${taxonomyId}/orgs/`, + getApiBaseUrl(), +).href; + +/** + * Build the mutation to assign organizations to a taxonomy. + */ +export const useManageOrgs = () => { + const queryClient = useQueryClient(); + return useMutation({ + /** + * @type {import("@tanstack/react-query").MutateFunction< + * any, + * any, + * { + * taxonomyId: number, + * orgs?: string[], + * allOrgs: boolean, + * } + * >} + */ + mutationFn: async ({ taxonomyId, orgs, allOrgs }) => { + const { data } = await getAuthenticatedHttpClient().put( + getManageOrgsApiUrl(taxonomyId), + { + all_orgs: allOrgs, + orgs: allOrgs ? undefined : orgs, + }, + ); + + return camelCaseObject(data); + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: ['taxonomyList'], + }); + queryClient.invalidateQueries({ + queryKey: ['taxonomyDetail', variables.taxonomyId], + }); + }, + }); +}; diff --git a/src/taxonomy/manage-orgs/data/api.test.jsx b/src/taxonomy/manage-orgs/data/api.test.jsx new file mode 100644 index 0000000000..40f047f6e6 --- /dev/null +++ b/src/taxonomy/manage-orgs/data/api.test.jsx @@ -0,0 +1,81 @@ +import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { renderHook } from '@testing-library/react-hooks'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import MockAdapter from 'axios-mock-adapter'; + +import { + getManageOrgsApiUrl, + useManageOrgs, + +} from './api'; + +let axiosMock; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +const wrapper = ({ children }) => ( + + {children} + +); + +describe('import taxonomy api calls', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should call update taxonomy orgs', async () => { + axiosMock.onPut(getManageOrgsApiUrl(1)).reply(200); + const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useManageOrgs(), { wrapper }); + + await result.current.mutateAsync({ taxonomyId: 1, orgs: ['org1', 'org2'], allOrgs: false }); + expect(axiosMock.history.put[0].url).toEqual(getManageOrgsApiUrl(1)); + expect(axiosMock.history.put[0].data).toEqual(JSON.stringify({ all_orgs: false, orgs: ['org1', 'org2'] })); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['taxonomyList'], + }); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['taxonomyDetail', 1], + }); + }); + + it('should call update taxonomy orgs with allOrgs', async () => { + axiosMock.onPut(getManageOrgsApiUrl(1)).reply(200); + const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useManageOrgs(), { wrapper }); + + await result.current.mutateAsync({ taxonomyId: 1, orgs: ['org1', 'org2'], allOrgs: true }); + expect(axiosMock.history.put[0].url).toEqual(getManageOrgsApiUrl(1)); + // Should not send orgs when allOrgs is true + expect(axiosMock.history.put[0].data).toEqual(JSON.stringify({ all_orgs: true })); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['taxonomyList'], + }); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['taxonomyDetail', 1], + }); + }); +}); diff --git a/src/taxonomy/manage-orgs/index.js b/src/taxonomy/manage-orgs/index.js new file mode 100644 index 0000000000..9006d2be41 --- /dev/null +++ b/src/taxonomy/manage-orgs/index.js @@ -0,0 +1 @@ +export { default as ManageOrgsModal } from './ManageOrgsModal'; // eslint-disable-line import/prefer-default-export diff --git a/src/taxonomy/manage-orgs/messages.js b/src/taxonomy/manage-orgs/messages.js new file mode 100644 index 0000000000..3bfeafe585 --- /dev/null +++ b/src/taxonomy/manage-orgs/messages.js @@ -0,0 +1,65 @@ +// @ts-check +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + headerTitle: { + id: 'course-authoring.taxonomy-manage-orgs.header.title', + defaultMessage: 'Assign to organizations', + }, + bodyText: { + id: 'course-authoring.taxonomy-manage-orgs.body.text', + defaultMessage: 'Manage which organizations can access the taxonomy by assigning them in the menu below. You can ' + + 'also choose to assign the taxonomy to all organizations.', + }, + assignOrgs: { + id: 'course-authoring.taxonomy-manage-orgs.assign-orgs', + defaultMessage: 'Assign organizations', + }, + currentAssignments: { + id: 'course-authoring.taxonomy-manage-orgs.current-assignments', + defaultMessage: 'Currently assigned:', + }, + addOrganizations: { + id: 'course-authoring.taxonomy-manage-orgs.add-orgs', + defaultMessage: 'Add another organization:', + }, + searchOrganizations: { + id: 'course-authoring.taxonomy-manage-orgs.search-orgs', + defaultMessage: 'Search for an organization', + }, + noOrganizationAssigned: { + id: 'course-authoring.taxonomy-manage-orgs.no-orgs', + defaultMessage: 'No organizations assigned', + }, + assignAll: { + id: 'course-authoring.taxonomy-manage-orgs.assign-all', + defaultMessage: 'Assign to all organizations', + }, + cancelButton: { + id: 'course-authoring.taxonomy-manage-orgs.button.cancel', + defaultMessage: 'Cancel', + }, + saveButton: { + id: 'course-authoring.taxonomy-manage-orgs.button.save', + defaultMessage: 'Save', + }, + confirmUnassignTitle: { + id: 'course-authoring.taxonomy-manage-orgs.confirm-dialog.title', + defaultMessage: 'Unassign taxonomy', + }, + confirmUnassignText: { + id: 'course-authoring.taxonomy-manage-orgs.confirm-dialog.text', + defaultMessage: 'Content authors from unassigned organizations will not be able to tag course content with ' + + '{taxonomyName}. Are you sure you want to continue?', + }, + continueButton: { + id: 'course-authoring.taxonomy-manage-orgs.confirm-dialog.button.continue', + defaultMessage: 'Continue', + }, + assignOrgsSuccess: { + id: 'course-authoring.taxonomy-manage-orgs.toast.assign-orgs-success', + defaultMessage: 'Assigned organizations updated', + }, +}); + +export default messages; diff --git a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx index 9efd86e42c..486d7ac165 100644 --- a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx +++ b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx @@ -17,7 +17,7 @@ import taxonomyMessages from '../messages'; import { TagListTable } from '../tag-list'; import { TaxonomyMenu } from '../taxonomy-menu'; import TaxonomyDetailSideCard from './TaxonomyDetailSideCard'; -import { useTaxonomyDetailDataResponse, useTaxonomyDetailDataStatus } from './data/apiHooks'; +import { useTaxonomyDetailDataResponse, useTaxonomyDetailDataStatus } from '../data/apiHooks'; import SystemDefinedBadge from '../system-defined-badge'; const TaxonomyDetailPage = () => { diff --git a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx index 1f245356b6..49916995a5 100644 --- a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx +++ b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx @@ -1,22 +1,21 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { AppProvider } from '@edx/frontend-platform/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render } from '@testing-library/react'; +import MockAdapter from 'axios-mock-adapter'; -import { useTaxonomyDetailData } from './data/api'; +import { getTaxonomyApiUrl } from '../data/api'; import initializeStore from '../../store'; import TaxonomyDetailPage from './TaxonomyDetailPage'; -import { TaxonomyContext } from '../common/context'; let store; const mockNavigate = jest.fn(); const mockMutate = jest.fn(); -const mockSetToastMessage = jest.fn(); +let axiosMock; -jest.mock('./data/api', () => ({ - useTaxonomyDetailData: jest.fn(), -})); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), // use actual for all non-hook parts useParams: () => ({ @@ -25,31 +24,27 @@ jest.mock('react-router-dom', () => ({ useNavigate: () => mockNavigate, })); jest.mock('../data/apiHooks', () => ({ + ...jest.requireActual('../data/apiHooks'), useDeleteTaxonomy: () => mockMutate, })); jest.mock('./TaxonomyDetailSideCard', () => jest.fn(() => <>Mock TaxonomyDetailSideCard)); jest.mock('../tag-list/TagListTable', () => jest.fn(() => <>Mock TagListTable)); -const RootWrapper = () => { - const context = useMemo(() => ({ - toastMessage: null, - setToastMessage: mockSetToastMessage, - }), []); - - return ( - - - - - - - - ); -}; - -describe('', async () => { - beforeEach(async () => { +const queryClient = new QueryClient(); + +const RootWrapper = () => ( + + + + + + + +); + +describe('', () => { + beforeEach(() => { initializeMockApp({ authenticatedUser: { userId: 3, @@ -59,71 +54,70 @@ describe('', async () => { }, }); store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); }); - it('shows the spinner before the query is complete', async () => { - useTaxonomyDetailData.mockReturnValue({ - isFetched: false, - }); + afterEach(() => { + jest.clearAllMocks(); + axiosMock.restore(); + queryClient.clear(); + }); + + it('shows the spinner before the query is complete', () => { + // Use unresolved promise to keep the Loading visible + axiosMock.onGet(getTaxonomyApiUrl(1)).reply(() => new Promise()); const { getByRole } = render(); const spinner = getByRole('status'); expect(spinner.textContent).toEqual('Loading...'); }); - it('shows the connector error component if got some error', async () => { - useTaxonomyDetailData.mockReturnValue({ - isFetched: true, - isError: true, - }); - const { getByTestId } = render(); - expect(getByTestId('connectionErrorAlert')).toBeInTheDocument(); + it('shows the connector error component if not taxonomy returned', async () => { + // Use empty response to trigger the error. Returning an error do not + // work because the query will retry. + axiosMock.onGet(getTaxonomyApiUrl(1)).reply(200); + + const { findByTestId } = render(); + + expect(await findByTestId('connectionErrorAlert')).toBeInTheDocument(); }); it('should render page and page title correctly', async () => { - useTaxonomyDetailData.mockReturnValue({ - isSuccess: true, - isFetched: true, - isError: false, - data: { - id: 1, - name: 'Test taxonomy', - description: 'This is a description', - systemDefined: true, - }, + await axiosMock.onGet(getTaxonomyApiUrl(1)).replyOnce(200, { + id: 1, + name: 'Test taxonomy', + description: 'This is a description', + system_defined: false, }); - const { getByRole } = render(); - expect(getByRole('heading')).toHaveTextContent('Test taxonomy'); + + const { findByRole } = render(); + + expect(await findByRole('heading')).toHaveTextContent('Test taxonomy'); }); it('should show system defined badge', async () => { - useTaxonomyDetailData.mockReturnValue({ - isSuccess: true, - isFetched: true, - isError: false, - data: { - id: 1, - name: 'Test taxonomy', - description: 'This is a description', - systemDefined: true, - }, + axiosMock.onGet(getTaxonomyApiUrl(1)).replyOnce(200, { + id: 1, + name: 'Test taxonomy', + description: 'This is a description', + system_defined: true, }); - const { getByText } = render(); + + const { findByRole, getByText } = render(); + expect(await findByRole('heading')).toHaveTextContent('Test taxonomy'); expect(getByText('System-level')).toBeInTheDocument(); }); it('should not show system defined badge', async () => { - useTaxonomyDetailData.mockReturnValue({ - isSuccess: true, - isFetched: true, - isError: false, - data: { - id: 1, - name: 'Test taxonomy', - description: 'This is a description', - systemDefined: false, - }, + axiosMock.onGet(getTaxonomyApiUrl(1)).replyOnce(200, { + id: 1, + name: 'Test taxonomy', + description: 'This is a description', + system_defined: false, }); - const { queryByText } = render(); + + const { findByRole, queryByText } = render(); + + expect(await findByRole('heading')).toHaveTextContent('Test taxonomy'); expect(queryByText('System-level')).not.toBeInTheDocument(); }); }); diff --git a/src/taxonomy/taxonomy-detail/data/api.js b/src/taxonomy/taxonomy-detail/data/api.js deleted file mode 100644 index 3cc9126059..0000000000 --- a/src/taxonomy/taxonomy-detail/data/api.js +++ /dev/null @@ -1,28 +0,0 @@ -// @ts-check - -// TODO: this file needs to be merged into src/taxonomy/data/api.js -// We are creating a mess with so many different /data/[api|types].js files in subfolders. -// There is only one tagging/taxonomy API, and it should be implemented via a single types.mjs and api.js file. - -import { camelCaseObject, getConfig } from '@edx/frontend-platform'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { useQuery } from '@tanstack/react-query'; - -const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; -const getTaxonomyDetailApiUrl = (taxonomyId) => new URL( - `api/content_tagging/v1/taxonomies/${taxonomyId}/`, - getApiBaseUrl(), -).href; - -/** - * @param {number} taxonomyId - * @returns {import('@tanstack/react-query').UseQueryResult} - */ // eslint-disable-next-line import/prefer-default-export -export const useTaxonomyDetailData = (taxonomyId) => ( - useQuery({ - queryKey: ['taxonomyDetail', taxonomyId], - queryFn: () => getAuthenticatedHttpClient().get(getTaxonomyDetailApiUrl(taxonomyId)) - .then((response) => response.data) - .then(camelCaseObject), - }) -); diff --git a/src/taxonomy/taxonomy-detail/data/api.test.js b/src/taxonomy/taxonomy-detail/data/api.test.js deleted file mode 100644 index 257421680c..0000000000 --- a/src/taxonomy/taxonomy-detail/data/api.test.js +++ /dev/null @@ -1,27 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { - useTaxonomyDetailData, -} from './api'; - -const mockHttpClient = { - get: jest.fn(), -}; - -jest.mock('@tanstack/react-query', () => ({ - useQuery: jest.fn(), -})); - -jest.mock('@edx/frontend-platform/auth', () => ({ - getAuthenticatedHttpClient: jest.fn(() => mockHttpClient), -})); - -describe('useTaxonomyDetailData', () => { - it('should call useQuery with the correct parameters', () => { - useTaxonomyDetailData('1'); - - expect(useQuery).toHaveBeenCalledWith({ - queryKey: ['taxonomyDetail', '1'], - queryFn: expect.any(Function), - }); - }); -}); diff --git a/src/taxonomy/taxonomy-detail/data/apiHooks.jsx b/src/taxonomy/taxonomy-detail/data/apiHooks.jsx deleted file mode 100644 index 7a06b33618..0000000000 --- a/src/taxonomy/taxonomy-detail/data/apiHooks.jsx +++ /dev/null @@ -1,36 +0,0 @@ -// @ts-check -import { - useTaxonomyDetailData, -} from './api'; - -/** - * @param {number} taxonomyId - * @returns {Pick} - */ -export const useTaxonomyDetailDataStatus = (taxonomyId) => { - const { - isError, - error, - isFetched, - isSuccess, - } = useTaxonomyDetailData(taxonomyId); - return { - isError, - error, - isFetched, - isSuccess, - }; -}; - -/** - * @param {number} taxonomyId - * @returns {import("../../data/types.mjs").TaxonomyData | undefined} - */ -export const useTaxonomyDetailDataResponse = (taxonomyId) => { - const { isSuccess, data } = useTaxonomyDetailData(taxonomyId); - if (isSuccess) { - return data; - } - - return undefined; -}; diff --git a/src/taxonomy/taxonomy-detail/data/apiHooks.test.jsx b/src/taxonomy/taxonomy-detail/data/apiHooks.test.jsx deleted file mode 100644 index e69232c363..0000000000 --- a/src/taxonomy/taxonomy-detail/data/apiHooks.test.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { - useTaxonomyDetailDataStatus, - useTaxonomyDetailDataResponse, -} from './apiHooks'; - -jest.mock('@tanstack/react-query', () => ({ - useQuery: jest.fn(), -})); - -describe('useTaxonomyDetailDataStatus', () => { - it('should return status values', () => { - const status = { - isError: false, - error: undefined, - isFetched: true, - isSuccess: true, - }; - - useQuery.mockReturnValueOnce(status); - - const result = useTaxonomyDetailDataStatus(0); - - expect(result).toEqual(status); - }); -}); - -describe('useTaxonomyDetailDataResponse', () => { - it('should return data when status is success', () => { - useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' }); - - const result = useTaxonomyDetailDataResponse(); - - expect(result).toEqual('data'); - }); - - it('should return undefined when status is not success', () => { - useQuery.mockReturnValueOnce({ isSuccess: false }); - - const result = useTaxonomyDetailDataResponse(); - - expect(result).toBeUndefined(); - }); -}); diff --git a/src/taxonomy/taxonomy-detail/index.js b/src/taxonomy/taxonomy-detail/index.js index 5665033c97..8d14e0d50d 100644 --- a/src/taxonomy/taxonomy-detail/index.js +++ b/src/taxonomy/taxonomy-detail/index.js @@ -1,2 +1,2 @@ -// ts-check +// @ts-check export { default as TaxonomyDetailPage } from './TaxonomyDetailPage'; // eslint-disable-line import/prefer-default-export diff --git a/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx b/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx index 0db273cf46..730bc796e0 100644 --- a/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx +++ b/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx @@ -18,6 +18,7 @@ import { useDeleteTaxonomy } from '../data/apiHooks'; import { TaxonomyContext } from '../common/context'; import DeleteDialog from '../delete-dialog'; import { importTaxonomyTags } from '../import-tags'; +import { ManageOrgsModal } from '../manage-orgs'; import messages from './messages'; const TaxonomyMenu = ({ @@ -45,6 +46,7 @@ const TaxonomyMenu = ({ const [isDeleteDialogOpen, deleteDialogOpen, deleteDialogClose] = useToggle(false); const [isExportModalOpen, exportModalOpen, exportModalClose] = useToggle(false); + const [isManageOrgsModalOpen, manageOrgsModalOpen, manageOrgsModalClose] = useToggle(false); /** * @typedef {Object} MenuItem @@ -72,6 +74,12 @@ const TaxonomyMenu = ({ // Hide delete menu item if taxonomy is system defined hide: taxonomy.systemDefined, }, + manageOrgs: { + title: intl.formatMessage(messages.manageOrgsMenu), + action: manageOrgsModalOpen, + // Hide import menu item if taxonomy is system defined + hide: taxonomy.systemDefined, + }, }; // Remove hidden menu items @@ -95,6 +103,13 @@ const TaxonomyMenu = ({ taxonomyId={taxonomy.id} /> )} + {isManageOrgsModalOpen && ( + + )} ); diff --git a/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx b/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx index d8a44033ea..3cd4df1f30 100644 --- a/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx +++ b/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx @@ -8,7 +8,7 @@ import PropTypes from 'prop-types'; import { TaxonomyContext } from '../common/context'; import initializeStore from '../../store'; -import { deleteTaxonomy, getTaxonomyExportFile } from '../data/api'; +import { deleteTaxonomy, getTaxonomy, getTaxonomyExportFile } from '../data/api'; import { importTaxonomyTags } from '../import-tags'; import { TaxonomyMenu } from '.'; @@ -24,6 +24,7 @@ jest.mock('../data/api', () => ({ ...jest.requireActual('../data/api'), getTaxonomyExportFile: jest.fn(), deleteTaxonomy: jest.fn(), + getTaxonomy: jest.fn(), })); const queryClient = new QueryClient(); @@ -128,6 +129,7 @@ describe.each([true, false])('', async (iconMenu) => // Check that the import menu is not show expect(queryByTestId('taxonomy-menu-import')).not.toBeInTheDocument(); + expect(queryByTestId('taxonomy-menu-manageOrgs')).not.toBeInTheDocument(); }); test('doesnt show freeText taxonomies disabled menus', () => { @@ -246,4 +248,34 @@ describe.each([true, false])('', async (iconMenu) => // Toast message shown expect(mockSetToastMessage).toBeCalledWith(`"${taxonomyName}" deleted`); }); + + it('should open manage orgs dialog menu click', async () => { + const { + findByText, getByTestId, getByText, queryByText, + } = render(); + + // We need to provide a taxonomy or the modal will not open + getTaxonomy.mockResolvedValue({ + id: 1, + name: 'Taxonomy 1', + orgs: [], + allOrgs: true, + }); + + // Modal closed + expect(queryByText('Assign to organizations')).not.toBeInTheDocument(); + + // Click on delete menu + fireEvent.click(getByTestId('taxonomy-menu-button')); + fireEvent.click(getByTestId('taxonomy-menu-manageOrgs')); + + // Modal opened + expect(await findByText('Assign to organizations')).toBeInTheDocument(); + + // Click on cancel button + fireEvent.click(getByText('Cancel')); + + // Modal closed + expect(queryByText('Assign to organizations')).not.toBeInTheDocument(); + }); }); diff --git a/src/taxonomy/taxonomy-menu/messages.js b/src/taxonomy/taxonomy-menu/messages.js index 7d2b105331..3a71118ccb 100644 --- a/src/taxonomy/taxonomy-menu/messages.js +++ b/src/taxonomy/taxonomy-menu/messages.js @@ -14,6 +14,10 @@ const messages = defineMessages({ id: 'course-authoring.taxonomy-menu.import.label', defaultMessage: 'Re-import', }, + manageOrgsMenu: { + id: 'course-authoring.taxonomy-menu.assign-orgs.label', + defaultMessage: 'Manage Organizations', + }, exportMenu: { id: 'course-authoring.taxonomy-menu.export.label', defaultMessage: 'Export',