feat: assign taxonomy to organizations [FC-0036] (#760)

This PR adds a UI to assign organizations to a Taxonomy.

Co-authored-by: Jillian <jill@opencraft.com>
This commit is contained in:
Rômulo Penido
2024-01-12 04:40:56 -03:00
committed by GitHub
parent bfcd3e6ff9
commit 1fef358f55
25 changed files with 1159 additions and 212 deletions

View File

@@ -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';

View File

@@ -47,9 +47,7 @@ const RootWrapper = () => (
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
<TaxonomyContext.Provider value={context}>
<QueryClientProvider client={queryClient}>
<TaxonomyListPage intl={injectIntl} />
</QueryClientProvider>
<TaxonomyListPage intl={injectIntl} />
</TaxonomyContext.Provider>
</QueryClientProvider>
</IntlProvider>
@@ -71,6 +69,10 @@ describe('<TaxonomyListPage />', () => {
axiosMock.onGet(organizationsListUrl).reply(200, organizations);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should render page and page title correctly', () => {
const { getByText } = render(<RootWrapper />);
expect(getByText('Taxonomies')).toBeInTheDocument();
@@ -134,7 +136,11 @@ describe('<TaxonomyListPage />', () => {
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 {

View File

@@ -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<import("./types.mjs").TaxonomyData>}
*/
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

View File

@@ -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';

View File

@@ -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<import('@tanstack/react-query').UseQueryResult, "error" | "isError" | "isFetched" | "isSuccess">}
*/
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;
};

View File

@@ -1,5 +1,6 @@
import { useQuery, useMutation } from '@tanstack/react-query';
import { act } from '@testing-library/react';
import {
useTaxonomyListDataResponse,
useIsTaxonomyListDataLoaded,

View File

@@ -12,6 +12,7 @@
* @property {boolean} visibleToAuthors
* @property {number} tagsCount
* @property {string[]} orgs
* @property {boolean} allOrgs
*/
/**

View File

@@ -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;
});
});
});

View File

@@ -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 (
<AlertModal
title={intl.formatMessage(messages.confirmUnassignTitle)}
isOpen={isOpen}
onClose={onClose}
variant="warning"
icon={Warning}
footerNode={(
<ActionRow>
<Button variant="tertiary" onClick={onClose}>
{intl.formatMessage(messages.cancelButton)}
</Button>
<Button variant="primary" onClick={confirm}>
{intl.formatMessage(messages.continueButton)}
</Button>
</ActionRow>
)}
>
<p>
{intl.formatMessage(messages.confirmUnassignText, { taxonomyName })}
</p>
</AlertModal>
);
};
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 (
<Container onClick={(e) => e.stopPropagation() /* This prevents calling onClick handler from the parent */}>
<ModalDialog
className="manage-orgs"
title={intl.formatMessage(messages.headerTitle)}
isOpen={isOpen}
onClose={onClose}
size="lg"
hasCloseButton
isFullscreenOnMobile
>
{isDialogDisabled && (
// This div is used to prevent the user from interacting with the dialog while it is disabled
<div className="position-absolute w-100 h-100 d-block zindex-9" />
)}
<ModalDialog.Header>
<ModalDialog.Title>
{intl.formatMessage(messages.headerTitle)}
</ModalDialog.Title>
</ModalDialog.Header>
<hr className="mx-4" />
<ModalDialog.Body>
<Form.Label>
<Stack>
<div className="pb-5">{intl.formatMessage(messages.bodyText)}</div>
<div>{intl.formatMessage(messages.currentAssignments)}</div>
<div className="col-9 d-inline-box overflow-auto">
{selectedOrgs.length ? selectedOrgs.map((org) => (
<Chip
key={org}
iconAfter={Close}
onIconAfterClick={() => setSelectedOrgs(selectedOrgs.filter((o) => o !== org))}
disabled={allOrgs}
>
{org}
</Chip>
)) : <span className="text-muted">{intl.formatMessage(messages.noOrganizationAssigned)}</span> }
</div>
</Stack>
</Form.Label>
<Form.Group>
<Form.Label>
{intl.formatMessage(messages.addOrganizations)}
</Form.Label>
<Form.Autosuggest
placeholder={intl.formatMessage(messages.searchOrganizations)}
onSelected={(org) => setSelectedOrgs([...selectedOrgs, org])}
disabled={allOrgs}
>
{organizationListData ? organizationListData.filter(o => !selectedOrgs?.includes(o)).map((org) => (
<Form.AutosuggestOption key={org}>{org}</Form.AutosuggestOption>
)) : [] }
</Form.Autosuggest>
</Form.Group>
<Form.Checkbox checked={allOrgs} onChange={(e) => setAllOrgs(e.target.checked)}>
{intl.formatMessage(messages.assignAll)}
</Form.Checkbox>
</ModalDialog.Body>
<hr className="mx-4" />
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton onClick={onClose} variant="tertiary">
{intl.formatMessage(messages.cancelButton)}
</ModalDialog.CloseButton>
<Button variant="primary" onClick={confirmSave} data-testid="save-button">
{intl.formatMessage(messages.saveButton)}
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
<ConfirmModal
isOpen={isConfirmModalOpen}
onClose={closeConfirmModal}
confirm={saveOrgs}
taxonomyName={taxonomy.name}
/>
</Container>
);
};
ManageOrgsModal.propTypes = {
taxonomyId: PropTypes.number.isRequired,
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
};
export default ManageOrgsModal;

View File

@@ -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;
}
}

View File

@@ -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 }) => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
<TaxonomyContext.Provider value={context}>
<ManageOrgsModal taxonomyId={taxonomy.id} isOpen onClose={onClose} />
</TaxonomyContext.Provider>
</QueryClientProvider>
</IntlProvider>
</AppProvider>
);
RootWrapper.propTypes = {
onClose: PropTypes.func.isRequired,
};
describe('<ManageOrgsModal />', () => {
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(<RootWrapper onClose={onClose} />);
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(<RootWrapper onClose={onClose} />);
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(<RootWrapper onClose={onClose} />);
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(<RootWrapper onClose={onClose} />);
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');
});
});

View File

@@ -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],
});
},
});
};

View File

@@ -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 }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
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],
});
});
});

View File

@@ -0,0 +1 @@
export { default as ManageOrgsModal } from './ManageOrgsModal'; // eslint-disable-line import/prefer-default-export

View File

@@ -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;

View File

@@ -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 = () => {

View File

@@ -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,
}), []);
const queryClient = new QueryClient();
return (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<TaxonomyContext.Provider value={context}>
<TaxonomyDetailPage />
</TaxonomyContext.Provider>
</IntlProvider>
</AppProvider>
);
};
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
<TaxonomyDetailPage />
</QueryClientProvider>
</IntlProvider>
</AppProvider>
);
describe('<TaxonomyDetailPage />', async () => {
beforeEach(async () => {
describe('<TaxonomyDetailPage />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
@@ -59,71 +54,70 @@ describe('<TaxonomyDetailPage />', 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(<RootWrapper />);
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(<RootWrapper />);
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(<RootWrapper />);
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(<RootWrapper />);
expect(getByRole('heading')).toHaveTextContent('Test taxonomy');
const { findByRole } = render(<RootWrapper />);
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(<RootWrapper />);
const { findByRole, getByText } = render(<RootWrapper />);
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(<RootWrapper />);
const { findByRole, queryByText } = render(<RootWrapper />);
expect(await findByRole('heading')).toHaveTextContent('Test taxonomy');
expect(queryByText('System-level')).not.toBeInTheDocument();
});
});

View File

@@ -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<import('../../data/types.mjs').TaxonomyData>}
*/ // 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),
})
);

View File

@@ -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),
});
});
});

View File

@@ -1,36 +0,0 @@
// @ts-check
import {
useTaxonomyDetailData,
} from './api';
/**
* @param {number} taxonomyId
* @returns {Pick<import('@tanstack/react-query').UseQueryResult, "error" | "isError" | "isFetched" | "isSuccess">}
*/
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;
};

View File

@@ -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();
});
});

View File

@@ -1,2 +1,2 @@
// ts-check
// @ts-check
export { default as TaxonomyDetailPage } from './TaxonomyDetailPage'; // eslint-disable-line import/prefer-default-export

View File

@@ -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 && (
<ManageOrgsModal
isOpen={isManageOrgsModalOpen}
onClose={manageOrgsModalClose}
taxonomyId={taxonomy.id}
/>
)}
</>
);

View File

@@ -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])('<TaxonomyMenu iconMenu=%s />', 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])('<TaxonomyMenu iconMenu=%s />', 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(<TaxonomyMenuComponent iconMenu={iconMenu} />);
// 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();
});
});

View File

@@ -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',