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:
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { act } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
useTaxonomyListDataResponse,
|
||||
useIsTaxonomyListDataLoaded,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* @property {boolean} visibleToAuthors
|
||||
* @property {number} tagsCount
|
||||
* @property {string[]} orgs
|
||||
* @property {boolean} allOrgs
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
301
src/taxonomy/import-tags/data/utils.test.js
Normal file
301
src/taxonomy/import-tags/data/utils.test.js
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
239
src/taxonomy/manage-orgs/ManageOrgsModal.jsx
Normal file
239
src/taxonomy/manage-orgs/ManageOrgsModal.jsx
Normal 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;
|
||||
13
src/taxonomy/manage-orgs/ManageOrgsModal.scss
Normal file
13
src/taxonomy/manage-orgs/ManageOrgsModal.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
208
src/taxonomy/manage-orgs/ManageOrgsModal.test.jsx
Normal file
208
src/taxonomy/manage-orgs/ManageOrgsModal.test.jsx
Normal 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');
|
||||
});
|
||||
});
|
||||
54
src/taxonomy/manage-orgs/data/api.js
Normal file
54
src/taxonomy/manage-orgs/data/api.js
Normal 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],
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
81
src/taxonomy/manage-orgs/data/api.test.jsx
Normal file
81
src/taxonomy/manage-orgs/data/api.test.jsx
Normal 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],
|
||||
});
|
||||
});
|
||||
});
|
||||
1
src/taxonomy/manage-orgs/index.js
Normal file
1
src/taxonomy/manage-orgs/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ManageOrgsModal } from './ManageOrgsModal'; // eslint-disable-line import/prefer-default-export
|
||||
65
src/taxonomy/manage-orgs/messages.js
Normal file
65
src/taxonomy/manage-orgs/messages.js
Normal 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;
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
);
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,2 +1,2 @@
|
||||
// ts-check
|
||||
// @ts-check
|
||||
export { default as TaxonomyDetailPage } from './TaxonomyDetailPage'; // eslint-disable-line import/prefer-default-export
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user