From a8b82cf4b03d413500764f1ed39d9a37ad7924f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 13 Dec 2023 14:37:22 -0300 Subject: [PATCH 01/25] feat: add manage-orgs modal --- src/taxonomy/manage-orgs/ManageOrgsModal.jsx | 125 +++++++++++++++++++ src/taxonomy/manage-orgs/index.js | 1 + src/taxonomy/manage-orgs/messages.js | 40 ++++++ src/taxonomy/taxonomy-detail/index.js | 5 +- src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx | 14 +++ src/taxonomy/taxonomy-menu/messages.js | 4 + 6 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 src/taxonomy/manage-orgs/ManageOrgsModal.jsx create mode 100644 src/taxonomy/manage-orgs/index.js create mode 100644 src/taxonomy/manage-orgs/messages.js diff --git a/src/taxonomy/manage-orgs/ManageOrgsModal.jsx b/src/taxonomy/manage-orgs/ManageOrgsModal.jsx new file mode 100644 index 0000000000..175ff802c1 --- /dev/null +++ b/src/taxonomy/manage-orgs/ManageOrgsModal.jsx @@ -0,0 +1,125 @@ +// @ts-check +import React, { useEffect, useRef, useState } from 'react'; +import { + ActionRow, + Button, + Chip, + Container, + Form, + ModalDialog, + Stack, +} from '@edx/paragon'; +import { + Close, +} from '@edx/paragon/icons'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import messages from './messages'; +import { useTaxonomyDetailDataResponse } from '../taxonomy-detail'; + +const ManageOrgsModal = ({ + taxonomyId, + isOpen, + onClose, +}) => { + const intl = useIntl(); + const [allOrgs, setAllOrgs] = useState(/** @type {null|string[]} */(null)); + const [selectedOrgs, setSelectedOrgs] = useState(/** @type {null|string[]} */(null)); + + const taxonomy = useTaxonomyDetailDataResponse(taxonomyId); + + useEffect(() => { + if (!allOrgs) { + setAllOrgs(Array.from(Array(100).keys()).map((i) => `org${i + 1}`)); + } + }, []); + + useEffect(() => { + if (taxonomy && !selectedOrgs) { + setSelectedOrgs([...taxonomy.orgs]); + } + }, [taxonomy]); + + if (!allOrgs || !selectedOrgs) { + return null; + } + + return ( + e.stopPropagation() /* This prevents calling onClick handler from the parent */}> + + + + {intl.formatMessage(messages.headerTitle)} + + + +
+ + + + + +
{intl.formatMessage(messages.bodyText)}
+
{intl.formatMessage(messages.currentAssignments)}
+
+ {selectedOrgs && selectedOrgs.map((org) => ( + setSelectedOrgs(selectedOrgs.filter((o) => o !== org))} + > + {org} + + ))} +
+
+
+ setSelectedOrgs([...selectedOrgs, org])} + > + {allOrgs.filter(o => !selectedOrgs?.includes(o)).map((org) => ( + {org} + ))} + +
+
+ +
+ + + + + {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/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..76946d0d67 --- /dev/null +++ b/src/taxonomy/manage-orgs/messages.js @@ -0,0 +1,40 @@ +// @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:', + }, + 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', + }, +}); + +export default messages; diff --git a/src/taxonomy/taxonomy-detail/index.js b/src/taxonomy/taxonomy-detail/index.js index 5665033c97..4f96044380 100644 --- a/src/taxonomy/taxonomy-detail/index.js +++ b/src/taxonomy/taxonomy-detail/index.js @@ -1,2 +1,3 @@ -// ts-check -export { default as TaxonomyDetailPage } from './TaxonomyDetailPage'; // eslint-disable-line import/prefer-default-export +// @ts-check +export { default as TaxonomyDetailPage } from './TaxonomyDetailPage'; +export { useTaxonomyDetailDataResponse, useTaxonomyDetailDataStatus } from './data/apiHooks'; diff --git a/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx b/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx index 70b8940589..173f507cc3 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 = ({ @@ -46,6 +47,7 @@ const TaxonomyMenu = ({ const [isDeleteDialogOpen, deleteDialogOpen, deleteDialogClose] = useToggle(false); const [isExportModalOpen, exportModalOpen, exportModalClose] = useToggle(false); + const [isManageOrgsModalOpen, manageOrgsModalOpen, manageOrgsModalClose] = useToggle(false); const getTaxonomyMenuItems = () => { let menuItems = { @@ -55,6 +57,11 @@ const TaxonomyMenu = ({ // Hide import menu item if taxonomy is system defined or allows free text hide: taxonomy.systemDefined || taxonomy.allowFreeText, }, + manageOrgs: { + title: intl.formatMessage(messages.manageOrgsMenu), + action: manageOrgsModalOpen, + hide: taxonomy.systemDefined, + }, export: { title: intl.formatMessage(messages.exportMenu), action: exportModalOpen, @@ -93,6 +100,13 @@ const TaxonomyMenu = ({ taxonomyId={taxonomy.id} /> )} + {isManageOrgsModalOpen && ( + + )} ); 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', From 6484bdcd7588ab4b19855a4f3c85360f6a526edd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 13 Dec 2023 18:23:52 -0300 Subject: [PATCH 02/25] fix: menu layout and input clear --- src/taxonomy/manage-orgs/ManageOrgsModal.jsx | 16 +++++++++++++++- src/taxonomy/manage-orgs/ManageOrgsModal.scss | 10 ++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 src/taxonomy/manage-orgs/ManageOrgsModal.scss diff --git a/src/taxonomy/manage-orgs/ManageOrgsModal.jsx b/src/taxonomy/manage-orgs/ManageOrgsModal.jsx index 175ff802c1..2f41cab49a 100644 --- a/src/taxonomy/manage-orgs/ManageOrgsModal.jsx +++ b/src/taxonomy/manage-orgs/ManageOrgsModal.jsx @@ -1,5 +1,5 @@ // @ts-check -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { ActionRow, Button, @@ -16,6 +16,7 @@ import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; import { useTaxonomyDetailDataResponse } from '../taxonomy-detail'; +import './ManageOrgsModal.scss'; const ManageOrgsModal = ({ taxonomyId, @@ -40,6 +41,18 @@ const ManageOrgsModal = ({ } }, [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('.pgn__form-group input')); + if (inputRef) { + inputRef.value = null; + const event = new Event('change', { bubbles: true }); + inputRef.dispatchEvent(event); + } + } + }, [selectedOrgs]); + if (!allOrgs || !selectedOrgs) { return null; } @@ -47,6 +60,7 @@ const ManageOrgsModal = ({ return ( e.stopPropagation() /* This prevents calling onClick handler from the parent */}> Date: Fri, 15 Dec 2023 20:27:13 -0300 Subject: [PATCH 03/25] feat: add api --- src/generic/data/apiHooks.js | 15 +++ src/taxonomy/manage-orgs/ManageOrgsModal.jsx | 93 ++++++++++++------- src/taxonomy/manage-orgs/ManageOrgsModal.scss | 14 +-- src/taxonomy/manage-orgs/data/api.js | 52 +++++++++++ src/taxonomy/manage-orgs/messages.js | 8 ++ src/taxonomy/taxonomy-detail/data/types.mjs | 1 + src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx | 2 +- 7 files changed, 145 insertions(+), 40 deletions(-) create mode 100644 src/generic/data/apiHooks.js create mode 100644 src/taxonomy/manage-orgs/data/api.js diff --git a/src/generic/data/apiHooks.js b/src/generic/data/apiHooks.js new file mode 100644 index 0000000000..5640878b20 --- /dev/null +++ b/src/generic/data/apiHooks.js @@ -0,0 +1,15 @@ +// @ts-check +import { useQuery } from '@tanstack/react-query'; +import { getOrganizations } from './api'; + +/** + * Builds the query to get a list of available organizations + */ +export const useOrganizationListData = () => ( + useQuery({ + queryKey: ['organizationList'], + queryFn: () => getOrganizations(), + }) +); + +export default useOrganizationListData; diff --git a/src/taxonomy/manage-orgs/ManageOrgsModal.jsx b/src/taxonomy/manage-orgs/ManageOrgsModal.jsx index 2f41cab49a..f219a9425c 100644 --- a/src/taxonomy/manage-orgs/ManageOrgsModal.jsx +++ b/src/taxonomy/manage-orgs/ManageOrgsModal.jsx @@ -1,5 +1,6 @@ // @ts-check import React, { useEffect, useState } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { ActionRow, Button, @@ -13,9 +14,11 @@ import { Close, } from '@edx/paragon/icons'; import PropTypes from 'prop-types'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import messages from './messages'; + +import { useOrganizationListData } from '../../generic/data/apiHooks'; import { useTaxonomyDetailDataResponse } from '../taxonomy-detail'; +import { useManageOrgs } from './data/api'; +import messages from './messages'; import './ManageOrgsModal.scss'; const ManageOrgsModal = ({ @@ -24,20 +27,42 @@ const ManageOrgsModal = ({ onClose, }) => { const intl = useIntl(); - const [allOrgs, setAllOrgs] = useState(/** @type {null|string[]} */(null)); const [selectedOrgs, setSelectedOrgs] = useState(/** @type {null|string[]} */(null)); + const [allOrgs, setAllOrgs] = useState(/** @type {null|boolean} */(null)); + + const { + data: organizationListData, + } = useOrganizationListData(); const taxonomy = useTaxonomyDetailDataResponse(taxonomyId); - useEffect(() => { - if (!allOrgs) { - setAllOrgs(Array.from(Array(100).keys()).map((i) => `org${i + 1}`)); + const manageOrgMutation = useManageOrgs(); + + const saveOrgs = async () => { + if (selectedOrgs !== null && allOrgs !== null) { + try { + await manageOrgMutation.mutateAsync({ + taxonomyId, + orgs: selectedOrgs, + allOrgs, + }); + // ToDo: display a success message to the user + } catch (/** @type {any} */ error) { + // ToDo: display the error to the user + } finally { + onClose(); + } } - }, []); + }; useEffect(() => { - if (taxonomy && !selectedOrgs) { - setSelectedOrgs([...taxonomy.orgs]); + if (taxonomy) { + if (selectedOrgs === null) { + setSelectedOrgs([...taxonomy.orgs]); + } + if (allOrgs === null) { + setAllOrgs(taxonomy.allOrgs); + } } }, [taxonomy]); @@ -53,7 +78,7 @@ const ManageOrgsModal = ({ } }, [selectedOrgs]); - if (!allOrgs || !selectedOrgs) { + if (!selectedOrgs) { return null; } @@ -76,37 +101,41 @@ const ManageOrgsModal = ({
- + + + +
{intl.formatMessage(messages.bodyText)}
+
{intl.formatMessage(messages.currentAssignments)}
+
+ {selectedOrgs.length ? selectedOrgs.map((org) => ( + setSelectedOrgs(selectedOrgs.filter((o) => o !== org))} + > + {org} + + )) : {intl.formatMessage(messages.noOrganizationAssigned)} } +
+
+
- -
{intl.formatMessage(messages.bodyText)}
-
{intl.formatMessage(messages.currentAssignments)}
-
- {selectedOrgs && selectedOrgs.map((org) => ( - setSelectedOrgs(selectedOrgs.filter((o) => o !== org))} - > - {org} - - ))} -
-
+ {intl.formatMessage(messages.addOrganizations)}
setSelectedOrgs([...selectedOrgs, org])} > - {allOrgs.filter(o => !selectedOrgs?.includes(o)).map((org) => ( + {organizationListData.filter(o => !selectedOrgs?.includes(o)).map((org) => ( {org} ))}
+ setAllOrgs(e.target.checked)}> + {intl.formatMessage(messages.assignAll)} +

@@ -118,7 +147,7 @@ const ManageOrgsModal = ({ + + + )} + > +

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

+ + ); +}; + +ConfirmModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + close: PropTypes.func.isRequired, + confirm: PropTypes.func.isRequired, + taxonomyName: PropTypes.string.isRequired, +}; + const ManageOrgsModal = ({ taxonomyId, isOpen, @@ -30,6 +72,8 @@ const ManageOrgsModal = ({ const [selectedOrgs, setSelectedOrgs] = useState(/** @type {null|string[]} */(null)); const [allOrgs, setAllOrgs] = useState(/** @type {null|boolean} */(null)); + const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false); + const { data: organizationListData, } = useOrganizationListData(); @@ -39,6 +83,7 @@ const ManageOrgsModal = ({ const manageOrgMutation = useManageOrgs(); const saveOrgs = async () => { + closeConfirmModal(); if (selectedOrgs !== null && allOrgs !== null) { try { await manageOrgMutation.mutateAsync({ @@ -55,6 +100,14 @@ const ManageOrgsModal = ({ } }; + const confirmSave = async () => { + if (!selectedOrgs?.length && !allOrgs) { + openConfirmModal(); + } else { + await saveOrgs(); + } + }; + useEffect(() => { if (taxonomy) { if (selectedOrgs === null) { @@ -71,14 +124,14 @@ const ManageOrgsModal = ({ // 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('.pgn__form-group input')); if (inputRef) { - inputRef.value = null; + inputRef.value = ''; const event = new Event('change', { bubbles: true }); inputRef.dispatchEvent(event); } } }, [selectedOrgs]); - if (!selectedOrgs) { + if (!selectedOrgs || !taxonomy) { return null; } @@ -128,7 +181,7 @@ const ManageOrgsModal = ({ placeholder={intl.formatMessage(messages.searchOrganizations)} onSelected={(org) => setSelectedOrgs([...selectedOrgs, org])} > - {organizationListData.filter(o => !selectedOrgs?.includes(o)).map((org) => ( + {organizationListData && organizationListData.filter(o => !selectedOrgs?.includes(o)).map((org) => ( {org} ))} @@ -147,7 +200,7 @@ const ManageOrgsModal = ({ @@ -216,7 +214,7 @@ const ManageOrgsModal = ({
diff --git a/src/taxonomy/manage-orgs/ManageOrgsModal.test.jsx b/src/taxonomy/manage-orgs/ManageOrgsModal.test.jsx index 4394b56dee..ad4df94486 100644 --- a/src/taxonomy/manage-orgs/ManageOrgsModal.test.jsx +++ b/src/taxonomy/manage-orgs/ManageOrgsModal.test.jsx @@ -4,6 +4,7 @@ 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'; @@ -22,28 +23,27 @@ const taxonomy = { orgs: ['org1', 'org2'], }; -const orgs = ['org1', 'org2', 'org3']; +const orgs = ['org1', 'org2', 'org3', 'org4', 'org5']; jest.mock('../data/api', () => ({ ...jest.requireActual('../data/api'), getTaxonomy: jest.fn().mockResolvedValue(taxonomy), })); -jest.mock('../../generic/data/apiHooks', () => ({ - ...jest.requireActual('../../generic/data/apiHooks'), +jest.mock('../../generic/data/api', () => ({ + ...jest.requireActual('../../generic/data/api'), getOrganizations: jest.fn().mockResolvedValue(orgs), })); -// const mockUseImportTagsMutate = jest.fn(); +const mockUseManageOrgsMutate = jest.fn(); -// jest.mock('./data/api', () => ({ -// ...jest.requireActual('./data/api'), -// planImportTags: jest.fn(), -// useImportTags: jest.fn(() => ({ -// ...jest.requireActual('./data/api').useImportTags(), -// mutateAsync: mockUseImportTagsMutate, -// })), -// })); +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(); @@ -56,12 +56,12 @@ const context = { const queryClient = new QueryClient(); -const RootWrapper = ({ close }) => ( +const RootWrapper = ({ onClose }) => ( - + @@ -69,7 +69,7 @@ const RootWrapper = ({ close }) => ( ); RootWrapper.propTypes = { - close: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, }; describe('', () => { @@ -85,44 +85,92 @@ describe('', () => { store = initializeStore(); }); - it('should render the dialog and close on cancel', async () => { - const close = jest.fn(); - const { getByText, getByRole } = render(); + afterEach(() => { + jest.clearAllMocks(); + }); + 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' }); - cancelButton.click(); - expect(close).toHaveBeenCalled(); + fireEvent.click(cancelButton); + + expect(onClose).toHaveBeenCalled(); }); - it.each(['success', 'error'])('can assign orgs to taxonomies from the dialog (%p)', async (expectedResult) => { - const close = jest.fn(); - const { getByText } = render(); + it('can assign orgs to taxonomies from the dialog', async () => { + const onClose = jest.fn(); + const { queryAllByTestId, getByTestId, getByText } = render(); + + await checkDialogRender(getByText); + + const input = getByTestId('autosuggest-iconbutton'); + fireEvent.click(input); + + const list = queryAllByTestId('autosuggest-optionitem'); + expect(list.length).toBe(3); // Show org3, org4, org5 + expect(getByText('org3')).toBeInTheDocument(); + expect(getByText('org4')).toBeInTheDocument(); + expect(getByText('org5')).toBeInTheDocument(); + + // Select org3 + fireEvent.click(list[0]); + + fireEvent.click(getByTestId('save-button')); await waitFor(() => { - expect(getByText('Assign to organizations')).toBeInTheDocument(); + expect(mockUseManageOrgsMutate).toHaveBeenCalledWith({ + taxonomyId: taxonomy.id, + orgs: ['org1', 'org2', 'org3'], + allOrgs: false, + }); }); - // - // await waitFor(() => { - // expect(mockUseImportTagsMutate).toHaveBeenCalledWith({ taxonomyId: taxonomy.id, file: fileJson }); - // }); - - if (expectedResult === 'success') { - // Toast message shown - expect(mockSetToastMessage).toBeCalledWith('Assigned organizations updated'); - } else { - // ToDo: check error - // Alert message shown - // expect(mockSetAlertProps).toBeCalledWith( - // expect.objectContaining({ - // variant: 'danger', - // title: 'Import error', - // description: 'Test error', - // }), - // ); - } + + // Toast message shown + expect(mockSetToastMessage).toBeCalledWith('Assigned organizations updated'); + // ToDo: check error + // Alert message shown + // expect(mockSetAlertProps).toBeCalledWith( + // expect.objectContaining({ + // variant: 'danger', + // title: 'Import error', + // description: 'Test error', + // }), + // ); + }); + + 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'); }); }); diff --git a/src/taxonomy/manage-orgs/data/api.js b/src/taxonomy/manage-orgs/data/api.js index 9273c18f4d..499aa3c731 100644 --- a/src/taxonomy/manage-orgs/data/api.js +++ b/src/taxonomy/manage-orgs/data/api.js @@ -26,7 +26,7 @@ export const useManageOrgs = () => { * any, * { * taxonomyId: number, - * orgs: string[], + * orgs?: string[], * allOrgs: boolean, * } * >} From ca38b4340a82113848f222d5e15238344ddc0578 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 20 Dec 2023 18:45:49 -0300 Subject: [PATCH 16/25] test: add more tests --- .../manage-orgs/ManageOrgsModal.test.jsx | 57 ++++++++++--- src/taxonomy/manage-orgs/data/api.test.jsx | 81 +++++++++++++++++++ .../TaxonomyDetailPage.test.jsx | 1 - 3 files changed, 125 insertions(+), 14 deletions(-) create mode 100644 src/taxonomy/manage-orgs/data/api.test.jsx diff --git a/src/taxonomy/manage-orgs/ManageOrgsModal.test.jsx b/src/taxonomy/manage-orgs/ManageOrgsModal.test.jsx index ad4df94486..29e10729db 100644 --- a/src/taxonomy/manage-orgs/ManageOrgsModal.test.jsx +++ b/src/taxonomy/manage-orgs/ManageOrgsModal.test.jsx @@ -113,43 +113,42 @@ describe('', () => { it('can assign orgs to taxonomies from the dialog', async () => { const onClose = jest.fn(); - const { queryAllByTestId, getByTestId, getByText } = render(); + 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(3); // Show org3, org4, org5 + 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[0]); + fireEvent.click(list[1]); fireEvent.click(getByTestId('save-button')); await waitFor(() => { expect(mockUseManageOrgsMutate).toHaveBeenCalledWith({ taxonomyId: taxonomy.id, - orgs: ['org1', 'org2', 'org3'], + orgs: ['org1', 'org3'], allOrgs: false, }); }); // Toast message shown expect(mockSetToastMessage).toBeCalledWith('Assigned organizations updated'); - // ToDo: check error - // Alert message shown - // expect(mockSetAlertProps).toBeCalledWith( - // expect.objectContaining({ - // variant: 'danger', - // title: 'Import error', - // description: 'Test error', - // }), - // ); }); it('can assign all orgs to taxonomies from the dialog', async () => { @@ -173,4 +172,36 @@ describe('', () => { // 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.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/taxonomy-detail/TaxonomyDetailPage.test.jsx b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx index a5c023b380..e1c6febe26 100644 --- a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx +++ b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx @@ -79,7 +79,6 @@ describe('', () => { id: 1, name: 'Test taxonomy', description: 'This is a description', - systemDefined: true, }); const { getByRole } = render(); expect(getByRole('heading')).toHaveTextContent('Test taxonomy'); From 5d2b31449cae9940ca1a336f693e93ff41e4f610 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 20 Dec 2023 19:43:22 -0300 Subject: [PATCH 17/25] fix: typo --- src/taxonomy/manage-orgs/ManageOrgsModal.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/taxonomy/manage-orgs/ManageOrgsModal.jsx b/src/taxonomy/manage-orgs/ManageOrgsModal.jsx index 2e85985453..383c0f55b6 100644 --- a/src/taxonomy/manage-orgs/ManageOrgsModal.jsx +++ b/src/taxonomy/manage-orgs/ManageOrgsModal.jsx @@ -41,7 +41,7 @@ const ConfirmModal = ({ icon={Warning} footerNode={( -