feat: add import taxonomy feature [FC-0036] (#675)

This change adds a new button in the Taxonomy List to allow users to create new taxonomies by importing a CSV/JSON file.
This commit is contained in:
Rômulo Penido
2024-01-08 04:08:03 -03:00
committed by GitHub
parent 2205506b26
commit 6c0fc09075
28 changed files with 1147 additions and 716 deletions

237
package-lock.json generated
View File

@@ -17633,23 +17633,6 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/jest-circus/node_modules/jest-each": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz",
"integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==",
"optional": true,
"peer": true,
"dependencies": {
"@jest/types": "^29.6.3",
"chalk": "^4.0.0",
"jest-get-type": "^29.6.3",
"jest-util": "^29.7.0",
"pretty-format": "^29.7.0"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/jest-circus/node_modules/jest-get-type": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz",
@@ -18464,6 +18447,226 @@
"node": ">= 10.14.2"
}
},
"node_modules/jest-each": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz",
"integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==",
"optional": true,
"peer": true,
"dependencies": {
"@jest/types": "^29.6.3",
"chalk": "^4.0.0",
"jest-get-type": "^29.6.3",
"jest-util": "^29.7.0",
"pretty-format": "^29.7.0"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/jest-each/node_modules/@jest/schemas": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
"integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
"optional": true,
"peer": true,
"dependencies": {
"@sinclair/typebox": "^0.27.8"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/jest-each/node_modules/@jest/types": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz",
"integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==",
"optional": true,
"peer": true,
"dependencies": {
"@jest/schemas": "^29.6.3",
"@types/istanbul-lib-coverage": "^2.0.0",
"@types/istanbul-reports": "^3.0.0",
"@types/node": "*",
"@types/yargs": "^17.0.8",
"chalk": "^4.0.0"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/jest-each/node_modules/@sinclair/typebox": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
"integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
"optional": true,
"peer": true
},
"node_modules/jest-each/node_modules/@types/yargs": {
"version": "17.0.32",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz",
"integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==",
"optional": true,
"peer": true,
"dependencies": {
"@types/yargs-parser": "*"
}
},
"node_modules/jest-each/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"optional": true,
"peer": true,
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/jest-each/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"optional": true,
"peer": true,
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/jest-each/node_modules/ci-info": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
"integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/sibiraj-s"
}
],
"optional": true,
"peer": true,
"engines": {
"node": ">=8"
}
},
"node_modules/jest-each/node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"optional": true,
"peer": true,
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/jest-each/node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"optional": true,
"peer": true
},
"node_modules/jest-each/node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"optional": true,
"peer": true,
"engines": {
"node": ">=8"
}
},
"node_modules/jest-each/node_modules/jest-get-type": {
"version": "29.6.3",
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz",
"integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==",
"optional": true,
"peer": true,
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/jest-each/node_modules/jest-util": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz",
"integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==",
"optional": true,
"peer": true,
"dependencies": {
"@jest/types": "^29.6.3",
"@types/node": "*",
"chalk": "^4.0.0",
"ci-info": "^3.2.0",
"graceful-fs": "^4.2.9",
"picomatch": "^2.2.3"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/jest-each/node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
"integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
"optional": true,
"peer": true,
"dependencies": {
"@jest/schemas": "^29.6.3",
"ansi-styles": "^5.0.0",
"react-is": "^18.0.0"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/jest-each/node_modules/pretty-format/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"optional": true,
"peer": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/jest-each/node_modules/react-is": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
"integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
"optional": true,
"peer": true
},
"node_modules/jest-each/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"optional": true,
"peer": true,
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/jest-environment-jsdom": {
"version": "26.6.2",
"resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-26.6.2.tgz",

View File

@@ -1,4 +1,4 @@
import React, { useContext, useState } from 'react';
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import {
Button,
@@ -18,14 +18,15 @@ import {
} from '@edx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Helmet } from 'react-helmet';
import { useOrganizationListData } from '../generic/data/apiHooks';
import SubHeader from '../generic/sub-header/SubHeader';
import getPageHeadTitle from '../generic/utils';
import { getTaxonomyTemplateApiUrl } from './data/api';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './data/apiHooks';
import { importTaxonomy } from './import-tags';
import messages from './messages';
import TaxonomyCard from './taxonomy-card';
import { getTaxonomyTemplateApiUrl } from './data/api';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded, useDeleteTaxonomy } from './data/apiHooks';
import { useOrganizationListData } from '../generic/data/apiHooks';
import { TaxonomyContext } from './common/context';
const ALL_TAXONOMIES = 'All taxonomies';
const UNASSIGNED = 'Unassigned';
@@ -65,7 +66,11 @@ const TaxonomyListHeaderButtons = () => {
</Dropdown.Menu>
</Dropdown>
</OverlayTrigger>
<Button iconBefore={Add} disabled>
<Button
iconBefore={Add}
onClick={() => importTaxonomy(intl)}
data-testid="taxonomy-import-button"
>
{intl.formatMessage(messages.importButtonLabel)}
</Button>
</>
@@ -138,21 +143,8 @@ const OrganizationFilterSelector = ({
const TaxonomyListPage = () => {
const intl = useIntl();
const deleteTaxonomy = useDeleteTaxonomy();
const { setToastMessage } = useContext(TaxonomyContext);
const [selectedOrgFilter, setSelectedOrgFilter] = useState(ALL_TAXONOMIES);
const onDeleteTaxonomy = React.useCallback((id, name) => {
deleteTaxonomy({ pk: id }, {
onSuccess: async () => {
setToastMessage(intl.formatMessage(messages.taxonomyDeleteToast, { name }));
},
onError: async () => {
// TODO: display the error to the user
},
});
}, [setToastMessage]);
const {
data: organizationListData,
isSuccess: isOrganizationListLoaded,
@@ -221,7 +213,7 @@ const TaxonomyListPage = () => {
>
<CardView
className="bg-light-400 p-5"
CardComponent={(row) => TaxonomyCard({ ...row, onDeleteTaxonomy })}
CardComponent={(row) => TaxonomyCard(row)}
/>
</DataTable>
)}

View File

@@ -1,24 +1,22 @@
import React, { useMemo } from 'react';
import MockAdapter from 'axios-mock-adapter';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { act, render, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, fireEvent, render } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import initializeStore from '../store';
import { getTaxonomyTemplateApiUrl } from './data/api';
import TaxonomyListPage from './TaxonomyListPage';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './data/apiHooks';
import { importTaxonomy } from './import-tags';
import { TaxonomyContext } from './common/context';
let store;
let axiosMock;
const queryClient = new QueryClient();
const mockSetToastMessage = jest.fn();
const mockDeleteTaxonomy = jest.fn();
const taxonomies = [{
id: 1,
name: 'Taxonomy',
@@ -28,33 +26,35 @@ const organizationsListUrl = 'http://localhost:18010/organizations';
const organizations = ['Org 1', 'Org 2'];
jest.mock('./data/apiHooks', () => ({
...jest.requireActual('./data/apiHooks'),
useTaxonomyListDataResponse: jest.fn(),
useIsTaxonomyListDataLoaded: jest.fn(),
useDeleteTaxonomy: () => mockDeleteTaxonomy,
}));
jest.mock('./taxonomy-card/TaxonomyCardMenu', () => jest.fn(({ onClickMenuItem }) => (
// eslint-disable-next-line jsx-a11y/control-has-associated-label
<button type="button" data-testid="test-delete-button" onClick={() => onClickMenuItem('delete')} />
)));
const RootWrapper = () => {
const context = useMemo(() => ({
toastMessage: null,
setToastMessage: mockSetToastMessage,
}), []);
jest.mock('./import-tags', () => ({
importTaxonomy: jest.fn(),
}));
return (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
const context = {
toastMessage: null,
setToastMessage: jest.fn(),
};
const queryClient = new QueryClient();
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
<TaxonomyContext.Provider value={context}>
<QueryClientProvider client={queryClient}>
<TaxonomyListPage intl={injectIntl} />
</QueryClientProvider>
</TaxonomyContext.Provider>
</IntlProvider>
</AppProvider>
);
};
</QueryClientProvider>
</IntlProvider>
</AppProvider>
);
describe('<TaxonomyListPage />', () => {
beforeEach(async () => {
@@ -114,21 +114,21 @@ describe('<TaxonomyListPage />', () => {
expect(templateButton.href).toBe(getTaxonomyTemplateApiUrl(fileFormat.toLowerCase()));
});
it('should show the success toast after delete', async () => {
it('calls the import taxonomy action when the import button is clicked', async () => {
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useTaxonomyListDataResponse.mockReturnValue({
results: taxonomies,
results: [{
id: 1,
name: 'Taxonomy',
description: 'This is a description',
}],
});
mockDeleteTaxonomy.mockImplementationOnce(async (params, callbacks) => {
callbacks.onSuccess();
});
const { getByTestId, getByLabelText } = render(<RootWrapper />);
fireEvent.click(getByTestId('test-delete-button'));
fireEvent.change(getByLabelText('Type DELETE to confirm'), { target: { value: 'DELETE' } });
fireEvent.click(getByTestId('delete-button'));
expect(mockDeleteTaxonomy).toBeCalledTimes(1);
expect(mockSetToastMessage).toBeCalledWith(`"${taxonomies[0].name}" deleted`);
const { getByRole } = render(<RootWrapper />);
const importButton = getByRole('button', { name: 'Import' });
expect(importButton).toBeInTheDocument();
fireEvent.click(importButton);
expect(importTaxonomy).toHaveBeenCalled();
});
it('should show all "All taxonomies", "Unassigned" and org names in taxonomy org filter', async () => {

View File

@@ -1,7 +1,8 @@
// @ts-check
/* eslint-disable import/prefer-default-export */
import React from 'react';
export const TaxonomyContext = React.createContext({
toastMessage: null,
setToastMessage: null,
toastMessage: /** @type{null|string} */ (null),
setToastMessage: /** @type{null|function} */ (null),
});

View File

@@ -4,11 +4,13 @@
* @typedef {Object} TaxonomyData
* @property {number} id
* @property {string} name
* @property {string} description
* @property {boolean} enabled
* @property {boolean} allowMultiple
* @property {boolean} allowFreeText
* @property {boolean} systemDefined
* @property {boolean} visibleToAuthors
* @property {number} tagsCount
* @property {string[]} orgs
*/

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import {
ActionRow,
Button,
Container,
Form,
ModalDialog,
Icon,
@@ -36,55 +37,57 @@ const DeleteDialog = ({
}, [onClose, onDelete]);
return (
<ModalDialog
title={intl.formatMessage(messages.deleteDialogTitle, { taxonomyName })}
isOpen={isOpen}
onClose={onClose}
size="md"
hasCloseButton={false}
variant="warning"
className="taxonomy-delete-dialog"
>
<ModalDialog.Header>
<ModalDialog.Title>
<Icon src={Warning} className="d-inline-block text-warning warning-icon" />
{intl.formatMessage(messages.deleteDialogTitle, { taxonomyName })}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
<div className="mb-4">
{/* Delete `(?)` after implement get tags count of a taxonomy */}
{intl.formatMessage(messages.deleteDialogBody, {
tagsCount: tagsCount !== undefined ? tagsCount : '(?)',
})}
</div>
<Form.Group>
<Form.Label>
{intl.formatMessage(messages.deleteDialogConfirmLabel, {
deleteLabel: <b>{deleteLabel}</b>,
<Container onClick={(e) => e.stopPropagation() /* This prevents calling onClick handler from the parent */}>
<ModalDialog
title={intl.formatMessage(messages.deleteDialogTitle, { taxonomyName })}
isOpen={isOpen}
onClose={onClose}
size="md"
hasCloseButton={false}
variant="warning"
className="taxonomy-delete-dialog"
>
<ModalDialog.Header>
<ModalDialog.Title>
<Icon src={Warning} className="d-inline-block text-warning warning-icon" />
{intl.formatMessage(messages.deleteDialogTitle, { taxonomyName })}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
<div className="mb-4">
{/* Delete `(?)` after implement get tags count of a taxonomy */}
{intl.formatMessage(messages.deleteDialogBody, {
tagsCount: tagsCount !== undefined ? tagsCount : '(?)',
})}
</Form.Label>
<Form.Control
onChange={handleInputChange}
/>
</Form.Group>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.deleteDialogCancelLabel)}
</ModalDialog.CloseButton>
<Button
variant="primary"
disabled={deleteButtonDisabled}
onClick={onClickDelete}
data-testid="delete-button"
>
{intl.formatMessage(messages.deleteDialogDeleteLabel)}
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
</div>
<Form.Group>
<Form.Label>
{intl.formatMessage(messages.deleteDialogConfirmLabel, {
deleteLabel: <b>{deleteLabel}</b>,
})}
</Form.Label>
<Form.Control
onChange={handleInputChange}
/>
</Form.Group>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.deleteDialogCancelLabel)}
</ModalDialog.CloseButton>
<Button
variant="primary"
disabled={deleteButtonDisabled}
onClick={onClickDelete}
data-testid="delete-button"
>
{intl.formatMessage(messages.deleteDialogDeleteLabel)}
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
</Container>
);
};

View File

@@ -1,7 +1,9 @@
// @ts-check
import React, { useState } from 'react';
import {
ActionRow,
Button,
Container,
Form,
ModalDialog,
} from '@edx/paragon';
@@ -24,60 +26,62 @@ const ExportModal = ({
}, [onClose, taxonomyId, outputFormat]);
return (
<ModalDialog
title={intl.formatMessage(messages.exportModalTitle)}
isOpen={isOpen}
onClose={onClose}
size="lg"
hasCloseButton
isFullscreenOnMobile
className="taxonomy-export-modal"
>
<ModalDialog.Header>
<ModalDialog.Title>
{intl.formatMessage(messages.exportModalTitle)}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body className="pb-5 mt-2">
<Form.Group>
<Form.Label>
{intl.formatMessage(messages.exportModalBodyDescription)}
</Form.Label>
<Form.RadioSet
name="export-format"
value={outputFormat}
onChange={(e) => setOutputFormat(e.target.value)}
>
<Form.Radio
key={`export-csv-format-${taxonomyId}`}
value="csv"
<Container onClick={(e) => e.stopPropagation() /* This prevents calling onClick handler from the parent */}>
<ModalDialog
title={intl.formatMessage(messages.exportModalTitle)}
isOpen={isOpen}
onClose={onClose}
size="lg"
hasCloseButton
isFullscreenOnMobile
className="taxonomy-export-modal"
>
<ModalDialog.Header>
<ModalDialog.Title>
{intl.formatMessage(messages.exportModalTitle)}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body className="pb-5 mt-2">
<Form.Group>
<Form.Label>
{intl.formatMessage(messages.exportModalBodyDescription)}
</Form.Label>
<Form.RadioSet
name="export-format"
value={outputFormat}
onChange={(e) => setOutputFormat(e.target.value)}
>
{intl.formatMessage(messages.taxonomyCSVFormat)}
</Form.Radio>
<Form.Radio
key={`export-json-format-${taxonomyId}`}
value="json"
<Form.Radio
key={`export-csv-format-${taxonomyId}`}
value="csv"
>
{intl.formatMessage(messages.taxonomyCSVFormat)}
</Form.Radio>
<Form.Radio
key={`export-json-format-${taxonomyId}`}
value="json"
>
{intl.formatMessage(messages.taxonomyJSONFormat)}
</Form.Radio>
</Form.RadioSet>
</Form.Group>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.taxonomyModalsCancelLabel)}
</ModalDialog.CloseButton>
<Button
variant="primary"
onClick={onClickExport}
data-testid={`export-button-${taxonomyId}`}
>
{intl.formatMessage(messages.taxonomyJSONFormat)}
</Form.Radio>
</Form.RadioSet>
</Form.Group>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.taxonomyModalsCancelLabel)}
</ModalDialog.CloseButton>
<Button
variant="primary"
onClick={onClickExport}
data-testid={`export-button-${taxonomyId}`}
>
{intl.formatMessage(messages.exportModalSubmitButtonLabel)}
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
{intl.formatMessage(messages.exportModalSubmitButtonLabel)}
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
</Container>
);
};

View File

@@ -0,0 +1,2 @@
export { default as taxonomyImportMock } from './taxonomyImportMock';
export { default as tagImportMock } from './tagImportMock';

View File

@@ -0,0 +1,4 @@
export default {
name: 'Taxonomy name',
description: 'Taxonomy description',
};

View File

@@ -0,0 +1,4 @@
export default {
name: 'Taxonomy name',
description: 'Taxonomy description',
};

View File

@@ -0,0 +1,58 @@
// @ts-check
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getTaxonomyImportNewApiUrl = () => new URL(
'api/content_tagging/v1/taxonomies/import/',
getApiBaseUrl(),
).href;
/**
* @param {number} taxonomyId
* @returns {string}
*/
export const getTagsImportApiUrl = (taxonomyId) => new URL(
`api/content_tagging/v1/taxonomies/${taxonomyId}/tags/import/`,
getApiBaseUrl(),
).href;
/**
* Import a new taxonomy
* @param {string} taxonomyName
* @param {string} taxonomyDescription
* @param {File} file
* @returns {Promise<Object>}
*/
export async function importNewTaxonomy(taxonomyName, taxonomyDescription, file) {
const formData = new FormData();
formData.append('taxonomy_name', taxonomyName);
formData.append('taxonomy_description', taxonomyDescription);
formData.append('file', file);
const { data } = await getAuthenticatedHttpClient().post(
getTaxonomyImportNewApiUrl(),
formData,
);
return camelCaseObject(data);
}
/**
* Import tags to an existing taxonomy, overwriting existing tags
* @param {number} taxonomyId
* @param {File} file
* @returns {Promise<Object>}
*/
export async function importTags(taxonomyId, file) {
const formData = new FormData();
formData.append('file', file);
const { data } = await getAuthenticatedHttpClient().put(
getTagsImportApiUrl(taxonomyId),
formData,
);
return camelCaseObject(data);
}

View File

@@ -0,0 +1,48 @@
import MockAdapter from 'axios-mock-adapter';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { tagImportMock, taxonomyImportMock } from '../__mocks__';
import {
getTaxonomyImportNewApiUrl,
getTagsImportApiUrl,
importNewTaxonomy,
importTags,
} from './api';
let axiosMock;
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 import new taxonomy', async () => {
axiosMock.onPost(getTaxonomyImportNewApiUrl()).reply(201, taxonomyImportMock);
const result = await importNewTaxonomy('Taxonomy name', 'Taxonomy description');
expect(axiosMock.history.post[0].url).toEqual(getTaxonomyImportNewApiUrl());
expect(result).toEqual(taxonomyImportMock);
});
it('should call import tags', async () => {
axiosMock.onPut(getTagsImportApiUrl(1)).reply(200, tagImportMock);
const result = await importTags(1);
expect(axiosMock.history.put[0].url).toEqual(getTagsImportApiUrl(1));
expect(result).toEqual(tagImportMock);
});
});

View File

@@ -0,0 +1,123 @@
// @ts-check
import messages from '../messages';
import { importNewTaxonomy, importTags } from './api';
/*
* This function get a file from the user. It does this by creating a
* file input element, and then clicking it. This allows us to get a file
* from the user without using a form. The file input element is created
* and appended to the DOM, then clicked. When the user selects a file,
* the change event is fired, and the file is resolved.
* The file input element is then removed from the DOM.
*/
/* istanbul ignore next */
const selectFile = async () => new Promise((resolve) => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.json,.csv';
fileInput.style.display = 'none';
fileInput.addEventListener('change', (/** @type { Event & { target: HTMLInputElement} } */ event) => {
const file = event.target.files?.[0];
if (!file) {
resolve(null);
}
resolve(file);
document.body.removeChild(fileInput);
}, false);
fileInput.addEventListener('cancel', () => {
resolve(null);
document.body.removeChild(fileInput);
}, false);
document.body.appendChild(fileInput);
// Calling click() directly was not working as expected, so we use setTimeout
// to ensure the file input is added to the DOM before clicking it.
setTimeout(() => fileInput.click(), 0);
});
/* istanbul ignore next */
export const importTaxonomy = async (intl) => {
/*
* This function is a temporary "Barebones" implementation of the import
* functionality with `prompt` and `alert`. It is intended to be replaced
* with a component that shows a `ModalDialog` in the future.
* See: https://github.com/openedx/modular-learning/issues/116
*/
/* eslint-disable no-alert */
/* eslint-disable no-console */
const getTaxonomyName = () => {
let taxonomyName = null;
while (!taxonomyName) {
taxonomyName = prompt(intl.formatMessage(messages.promptTaxonomyName));
if (taxonomyName == null) {
break;
}
if (!taxonomyName) {
alert(intl.formatMessage(messages.promptTaxonomyNameRequired));
}
}
return taxonomyName;
};
const getTaxonomyDescription = () => prompt(intl.formatMessage(messages.promptTaxonomyDescription));
const file = await selectFile();
if (!file) {
return;
}
const taxonomyName = getTaxonomyName();
if (taxonomyName == null) {
return;
}
const taxonomyDescription = getTaxonomyDescription();
if (taxonomyDescription == null) {
return;
}
importNewTaxonomy(taxonomyName, taxonomyDescription, file)
.then(() => {
alert(intl.formatMessage(messages.importTaxonomySuccess));
})
.catch((error) => {
alert(intl.formatMessage(messages.importTaxonomyError));
console.error(error.response);
});
};
/* istanbul ignore next */
export const importTaxonomyTags = async (taxonomyId, intl) => {
/*
* This function is a temporary "Barebones" implementation of the import
* functionality with `confirm` and `alert`. It is intended to be replaced
* with a component that shows a `ModalDialog` in the future.
* See: https://github.com/openedx/modular-learning/issues/126
*/
/* eslint-disable no-alert */
/* eslint-disable no-console */
const file = await selectFile();
if (!file) {
return;
}
if (!window.confirm(intl.formatMessage(messages.confirmImportTags))) {
return;
}
importTags(taxonomyId, file)
.then(() => {
alert(intl.formatMessage(messages.importTaxonomySuccess));
})
.catch((error) => {
alert(intl.formatMessage(messages.importTaxonomyError));
console.error(error.response);
});
};

View File

@@ -0,0 +1,2 @@
// @ts-check
export { importTaxonomyTags, importTaxonomy } from './data/utils';

View File

@@ -0,0 +1,33 @@
// ts-check
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
promptTaxonomyName: {
id: 'course-authoring.import-tags.prompt.taxonomy-name',
defaultMessage: 'Enter a name for the new taxonomy',
},
promptTaxonomyNameRequired: {
id: 'course-authoring.import-tags.prompt.taxonomy-name.required',
defaultMessage: 'You must enter a name for the new taxonomy',
},
promptTaxonomyDescription: {
id: 'course-authoring.import-tags.prompt.taxonomy-description',
defaultMessage: 'Enter a description for the new taxonomy',
},
importTaxonomySuccess: {
id: 'course-authoring.import-tags.success',
defaultMessage: 'Taxonomy imported successfully',
},
importTaxonomyError: {
id: 'course-authoring.import-tags.error',
defaultMessage: 'Import failed - see details in the browser console',
},
confirmImportTags: {
id: 'course-authoring.import-tags.warning',
defaultMessage: 'Warning! You are about to overwrite all tags in this taxonomy. Any tags applied to course'
+ ' content will be updated or removed. This cannot be undone.'
+ '\n\nAre you sure you want to continue importing this file?',
},
});
export default messages;

View File

@@ -22,21 +22,4 @@
max-height: 190px;
-webkit-line-clamp: 6;
}
.taxonomy-menu-item:focus {
/**
* There is a bug in the menu that auto focus the first item.
* We convert the focus style to a normal style.
*/
background-color: white !important;
font-weight: normal !important;
}
.taxonomy-menu-item:focus:hover {
/**
* Check the previous block about the focus.
* This enable a normal hover to focused items.
*/
background-color: $light-500 !important;
}
}

View File

@@ -2,16 +2,15 @@ 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 { render, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render } from '@testing-library/react';
import PropTypes from 'prop-types';
import initializeStore from '../../store';
import { getTaxonomyExportFile } from '../data/api';
import TaxonomyCard from '.';
let store;
const taxonomyId = 1;
const onDeleteTaxonomy = jest.fn();
const data = {
id: taxonomyId,
@@ -19,17 +18,16 @@ const data = {
description: 'This is a description',
};
jest.mock('../data/api', () => ({
getTaxonomyExportFile: jest.fn(),
}));
const queryClient = new QueryClient();
const TaxonomyCardComponent = ({ original }) => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<TaxonomyCard
original={original}
onDeleteTaxonomy={onDeleteTaxonomy}
/>
<QueryClientProvider client={queryClient}>
<TaxonomyCard
original={original}
/>
</QueryClientProvider>
</IntlProvider>
</AppProvider>
);
@@ -65,8 +63,8 @@ describe('<TaxonomyCard />', async () => {
});
it('not show the system-defined badge with normal taxonomies', () => {
const { getByText } = render(<TaxonomyCardComponent original={data} />);
expect(() => getByText('System-level')).toThrow();
const { queryByText } = render(<TaxonomyCardComponent original={data} />);
expect(queryByText('System-level')).not.toBeInTheDocument();
});
it('shows the system-defined badge with system taxonomies', () => {
@@ -80,8 +78,8 @@ describe('<TaxonomyCard />', async () => {
});
it('not show org count with taxonomies without orgs', () => {
const { getByText } = render(<TaxonomyCardComponent original={data} />);
expect(() => getByText('Assigned to 0 orgs')).toThrow();
const { queryByText } = render(<TaxonomyCardComponent original={data} />);
expect(queryByText('Assigned to 0 orgs')).not.toBeInTheDocument();
});
it('shows org count with taxonomies with orgs', () => {
@@ -92,111 +90,4 @@ describe('<TaxonomyCard />', async () => {
const { getByText } = render(<TaxonomyCardComponent original={cardData} />);
expect(getByText('Assigned to 6 orgs')).toBeInTheDocument();
});
test('should open and close menu on button click', () => {
const { getByTestId } = render(<TaxonomyCardComponent original={data} />);
// Menu closed/doesn't exist yet
expect(() => getByTestId('taxonomy-card-menu-1')).toThrow();
// Click on the menu button to open
fireEvent.click(getByTestId('taxonomy-card-menu-button-1'));
// Menu opened
expect(getByTestId('taxonomy-card-menu-1')).toBeVisible();
// Click on button again to close the menu
fireEvent.click(getByTestId('taxonomy-card-menu-button-1'));
// Menu closed
// Jest bug: toBeVisible() isn't checking opacity correctly
// expect(getByTestId('taxonomy-card-menu-1')).not.toBeVisible();
expect(getByTestId('taxonomy-card-menu-1').style.opacity).toEqual('0');
// Menu button still visible
expect(getByTestId('taxonomy-card-menu-button-1')).toBeVisible();
});
test('should open export modal on export menu click', () => {
const { getByTestId, getByText } = render(<TaxonomyCardComponent original={data} />);
// Modal closed
expect(() => getByText('Select format to export')).toThrow();
// Click on export menu
fireEvent.click(getByTestId('taxonomy-card-menu-button-1'));
fireEvent.click(getByTestId('taxonomy-card-menu-export-1'));
// Modal opened
expect(getByText('Select format to export')).toBeInTheDocument();
// Click on cancel button
fireEvent.click(getByText('Cancel'));
// Modal closed
expect(() => getByText('Select format to export')).toThrow();
});
test('should export a taxonomy', () => {
const { getByTestId, getByText } = render(<TaxonomyCardComponent original={data} />);
// Click on export menu
fireEvent.click(getByTestId('taxonomy-card-menu-button-1'));
fireEvent.click(getByTestId('taxonomy-card-menu-export-1'));
// Select JSON format and click on export
fireEvent.click(getByText('JSON file'));
fireEvent.click(getByTestId('export-button-1'));
// Modal closed
expect(() => getByText('Select format to export')).toThrow();
expect(getTaxonomyExportFile).toHaveBeenCalledWith(taxonomyId, 'json');
});
test('should open delete dialog on delete menu click', () => {
const { getByTestId, getByText } = render(<TaxonomyCardComponent original={data} />);
// Modal closed
expect(() => getByText(`Delete "${data.name}"`)).toThrow();
// Click on delete menu
fireEvent.click(getByTestId('taxonomy-card-menu-button-1'));
fireEvent.click(getByText('Delete'));
// Modal opened
expect(getByText(`Delete "${data.name}"`)).toBeInTheDocument();
// Click on cancel button
fireEvent.click(getByText('Cancel'));
// Modal closed
expect(() => getByText(`Delete "${data.name}"`)).toThrow();
});
test('should delete a taxonomy', () => {
const { getByTestId, getByText, getByLabelText } = render(<TaxonomyCardComponent original={data} />);
// Click on delete menu
fireEvent.click(getByTestId('taxonomy-card-menu-button-1'));
fireEvent.click(getByText('Delete'));
const deleteButton = getByTestId('delete-button');
// The delete button must to be disabled
expect(deleteButton).toBeDisabled();
// Testing delete button enabled/disabled changes
const input = getByLabelText('Type DELETE to confirm');
fireEvent.change(input, { target: { value: 'DELETE_INVALID' } });
expect(deleteButton).toBeDisabled();
fireEvent.change(input, { target: { value: 'DELETE' } });
expect(deleteButton).toBeEnabled();
// Click on delete button
fireEvent.click(deleteButton);
// Modal closed
expect(() => getByText(`Delete "${data.name}"`)).toThrow();
expect(onDeleteTaxonomy).toHaveBeenCalledWith(taxonomyId, data.name);
});
});

View File

@@ -1,62 +0,0 @@
import React from 'react';
import {
Dropdown,
IconButton,
Icon,
} from '@edx/paragon';
import { MoreVert } from '@edx/paragon/icons';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
const menuMessages = {
export: messages.taxonomyCardExportMenu,
delete: messages.taxonomyCardDeleteMenu,
};
const TaxonomyCardMenu = ({
id, name, onClickMenuItem, disabled, menuItems,
}) => {
const intl = useIntl();
const onClickItem = (menuName) => (e) => {
e.preventDefault();
onClickMenuItem(menuName);
};
return (
<Dropdown onToggle={(isOpen, ev) => ev.preventDefault()}>
<Dropdown.Toggle
as={IconButton}
src={MoreVert}
iconAs={Icon}
variant="primary"
alt={intl.formatMessage(messages.taxonomyMenuAlt, { name })}
id={`taxonomy-card-menu-button-${id}`}
data-testid={`taxonomy-card-menu-button-${id}`}
disabled={disabled}
/>
<Dropdown.Menu data-testid={`taxonomy-card-menu-${id}`}>
{ menuItems.map(item => (
<Dropdown.Item
className="taxonomy-menu-item"
data-testid={`taxonomy-card-menu-${item}-${id}`}
onClick={onClickItem(item)}
>
{intl.formatMessage(menuMessages[item])}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
);
};
TaxonomyCardMenu.propTypes = {
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
onClickMenuItem: PropTypes.func.isRequired,
disabled: PropTypes.bool.isRequired,
menuItems: PropTypes.arrayOf(PropTypes.string).isRequired,
};
export default TaxonomyCardMenu;

View File

@@ -8,10 +8,9 @@ import PropTypes from 'prop-types';
import { NavLink } from 'react-router-dom';
import classNames from 'classnames';
import { useIntl } from '@edx/frontend-platform/i18n';
import { TaxonomyMenu } from '../taxonomy-menu';
import messages from './messages';
import TaxonomyCardMenu from './TaxonomyCardMenu';
import ExportModal from '../export-modal';
import DeleteDialog from '../delete-dialog';
import SystemDefinedBadge from '../system-defined-badge';
const orgsCountEnabled = (orgsCount) => orgsCount !== undefined && orgsCount !== 0;
@@ -90,106 +89,50 @@ HeaderTitle.propTypes = {
title: PropTypes.string.isRequired,
};
const TaxonomyCard = ({ className, original, onDeleteTaxonomy }) => {
const TaxonomyCard = ({ className, original }) => {
const {
id, name, description, systemDefined, orgsCount, tagsCount,
id, name, description, systemDefined, orgsCount,
} = original;
const intl = useIntl();
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isMenuEnalbed, setIsMenuEnabled] = useState(true);
useEffect(() => {
// Resets the card to the initial state
setIsMenuEnabled(true);
}, [id]);
// Add here more menu item actions
const menuItemActions = {
export: () => setIsExportModalOpen(true),
delete: () => setIsDeleteDialogOpen(true),
};
const menuItems = ['export', 'delete'];
const systemDefinedMenuItems = ['export'];
const onClickMenuItem = (menuName) => (
menuItemActions[menuName]?.()
);
const onClickDeleteTaxonomy = () => {
setIsMenuEnabled(false);
onDeleteTaxonomy(id, name);
};
const getHeaderActions = () => {
let enabledMenuItems = menuItems;
if (systemDefined) {
enabledMenuItems = systemDefinedMenuItems;
}
return (
<TaxonomyCardMenu
id={id}
name={name}
onClickMenuItem={onClickMenuItem}
disabled={!isMenuEnalbed}
menuItems={enabledMenuItems}
/>
);
};
const renderExportModal = () => isExportModalOpen && (
<ExportModal
isOpen={isExportModalOpen}
onClose={() => setIsExportModalOpen(false)}
taxonomyId={id}
/>
);
const renderDeleteDialog = () => isDeleteDialogOpen && (
<DeleteDialog
isOpen={isDeleteDialogOpen}
onClose={() => setIsDeleteDialogOpen(false)}
onDelete={onClickDeleteTaxonomy}
taxonomyName={name}
tagsCount={tagsCount}
const getHeaderActions = () => (
<TaxonomyMenu
taxonomy={original}
iconMenu
/>
);
return (
<>
<Card
isClickable
as={NavLink}
to={`/taxonomy/${id}/`}
className={classNames('taxonomy-card', className)}
data-testid={`taxonomy-card-${id}`}
<Card
isClickable
as={NavLink}
to={`/taxonomy/${id}/`}
className={classNames('taxonomy-card', className)}
data-testid={`taxonomy-card-${id}`}
>
<Card.Header
title={<HeaderTitle taxonomyId={id} title={name} />}
subtitle={(
<HeaderSubtitle
id={id}
showSystemBadge={systemDefined}
orgsCount={orgsCount}
intl={intl}
/>
)}
actions={getHeaderActions()}
/>
<Card.Body className={classNames('taxonomy-card-body', {
'taxonomy-card-body-overflow-m': !systemDefined && !orgsCountEnabled(orgsCount),
'taxonomy-card-body-overflow-sm': systemDefined || orgsCountEnabled(orgsCount),
})}
>
<Card.Header
title={<HeaderTitle taxonomyId={id} title={name} />}
subtitle={(
<HeaderSubtitle
id={id}
showSystemBadge={systemDefined}
orgsCount={orgsCount}
intl={intl}
/>
)}
actions={getHeaderActions()}
/>
<Card.Body className={classNames('taxonomy-card-body', {
'taxonomy-card-body-overflow-m': !systemDefined && !orgsCountEnabled(orgsCount),
'taxonomy-card-body-overflow-sm': systemDefined || orgsCountEnabled(orgsCount),
})}
>
<Card.Section>
{description}
</Card.Section>
</Card.Body>
</Card>
{renderExportModal()}
{renderDeleteDialog()}
</>
<Card.Section>
{description}
</Card.Section>
</Card.Body>
</Card>
);
};
@@ -207,7 +150,6 @@ TaxonomyCard.propTypes = {
orgsCount: PropTypes.number,
tagsCount: PropTypes.number,
}).isRequired,
onDeleteTaxonomy: PropTypes.func.isRequired,
};
export default TaxonomyCard;

View File

@@ -5,18 +5,6 @@ const messages = defineMessages({
id: 'course-authoring.taxonomy-list.orgs-count.label',
defaultMessage: 'Assigned to {orgsCount} orgs',
},
taxonomyCardExportMenu: {
id: 'course-authoring.taxonomy-list.menu.export.label',
defaultMessage: 'Export',
},
taxonomyCardDeleteMenu: {
id: 'course-authoring.taxonomy-list.menu.delete.label',
defaultMessage: 'Delete',
},
taxonomyMenuAlt: {
id: 'course-authoring.taxonomy-list.menu.alt',
defaultMessage: '{name} menu',
},
});
export default messages;

View File

@@ -1,51 +0,0 @@
// ts-check
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Dropdown,
DropdownButton,
} from '@edx/paragon';
import PropTypes from 'prop-types';
import messages from './messages';
const menuMessages = {
export: messages.exportMenu,
delete: messages.deleteMenu,
};
const TaxonomyDetailMenu = ({
id, name, disabled, onClickMenuItem, menuItems,
}) => {
const intl = useIntl();
return (
<DropdownButton
id={id}
title={intl.formatMessage(messages.actionsButtonLabel)}
alt={intl.formatMessage(messages.actionsButtonAlt, { name })}
disabled={disabled}
>
{ menuItems.map(item => (
<Dropdown.Item
onClick={() => onClickMenuItem(item)}
>
{intl.formatMessage(menuMessages[item])}
</Dropdown.Item>
))}
</DropdownButton>
);
};
TaxonomyDetailMenu.propTypes = {
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
disabled: PropTypes.bool,
onClickMenuItem: PropTypes.func.isRequired,
menuItems: PropTypes.arrayOf(PropTypes.string).isRequired,
};
TaxonomyDetailMenu.defaultProps = {
disabled: false,
};
export default TaxonomyDetailMenu;

View File

@@ -1,4 +1,5 @@
import React, { useContext, useState } from 'react';
// @ts-check
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Breadcrumb,
@@ -6,56 +7,27 @@ import {
Layout,
} from '@edx/paragon';
import { Helmet } from 'react-helmet';
import { Link, useParams, useNavigate } from 'react-router-dom';
import { Link, useParams } from 'react-router-dom';
import ConnectionErrorAlert from '../../generic/ConnectionErrorAlert';
import Loading from '../../generic/Loading';
import getPageHeadTitle from '../../generic/utils';
import SubHeader from '../../generic/sub-header/SubHeader';
import taxonomyMessages from '../messages';
import TaxonomyDetailMenu from './TaxonomyDetailMenu';
import TaxonomyDetailSideCard from './TaxonomyDetailSideCard';
import { TagListTable } from '../tag-list';
import ExportModal from '../export-modal';
import { TaxonomyMenu } from '../taxonomy-menu';
import TaxonomyDetailSideCard from './TaxonomyDetailSideCard';
import { useTaxonomyDetailDataResponse, useTaxonomyDetailDataStatus } from './data/apiHooks';
import DeleteDialog from '../delete-dialog';
import { useDeleteTaxonomy } from '../data/apiHooks';
import { TaxonomyContext } from '../common/context';
import SystemDefinedBadge from '../system-defined-badge';
const TaxonomyDetailPage = () => {
const intl = useIntl();
const { taxonomyId: taxonomyIdString } = useParams();
const { setToastMessage } = useContext(TaxonomyContext);
const taxonomyId = Number(taxonomyIdString);
const taxonomy = useTaxonomyDetailDataResponse(taxonomyId);
const { isError, isFetched } = useTaxonomyDetailDataStatus(taxonomyId);
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const deleteTaxonomy = useDeleteTaxonomy();
const navigate = useNavigate();
const onClickDeleteTaxonomy = React.useCallback(() => {
deleteTaxonomy({ pk: taxonomy.id }, {
onSuccess: async () => {
setToastMessage(intl.formatMessage(taxonomyMessages.taxonomyDeleteToast, { name: taxonomy.name }));
navigate('/taxonomies');
},
onError: async () => {
// TODO: display the error to the user
},
});
}, [setToastMessage, taxonomy]);
const menuItems = ['export', 'delete'];
const systemDefinedMenuItems = ['export'];
const menuItemActions = {
export: () => setIsExportModalOpen(true),
delete: () => setIsDeleteDialogOpen(true),
};
if (!isFetched) {
return (
<Loading />
@@ -68,43 +40,12 @@ const TaxonomyDetailPage = () => {
);
}
const renderModals = () => isExportModalOpen && (
<ExportModal
isOpen={isExportModalOpen}
onClose={() => setIsExportModalOpen(false)}
taxonomyId={taxonomy.id}
const getHeaderActions = () => (
<TaxonomyMenu
taxonomy={taxonomy}
/>
);
const renderDeleteDialog = () => isDeleteDialogOpen && (
<DeleteDialog
isOpen={isDeleteDialogOpen}
onClose={() => setIsDeleteDialogOpen(false)}
onDelete={onClickDeleteTaxonomy}
taxonomyName={taxonomy.name}
tagsCount={0}
/>
);
const onClickMenuItem = (menuName) => (
menuItemActions[menuName]?.()
);
const getHeaderActions = () => {
let enabledMenuItems = menuItems;
if (taxonomy.systemDefined) {
enabledMenuItems = systemDefinedMenuItems;
}
return (
<TaxonomyDetailMenu
id={taxonomy.id}
name={taxonomy.name}
onClickMenuItem={onClickMenuItem}
menuItems={enabledMenuItems}
/>
);
};
const getSystemDefinedBadge = () => {
if (taxonomy.systemDefined) {
return <SystemDefinedBadge taxonomyId={taxonomyId} />;
@@ -152,8 +93,6 @@ const TaxonomyDetailPage = () => {
</Layout>
</Container>
</div>
{renderModals()}
{renderDeleteDialog()}
</>
);
};

View File

@@ -2,7 +2,7 @@ import React, { useMemo } from 'react';
import { initializeMockApp } from '@edx/frontend-platform';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { fireEvent, render } from '@testing-library/react';
import { render } from '@testing-library/react';
import { useTaxonomyDetailData } from './data/api';
import initializeStore from '../../store';
@@ -111,7 +111,7 @@ describe('<TaxonomyDetailPage />', async () => {
expect(getByText('System-level')).toBeInTheDocument();
});
it('should open export modal on export menu click', () => {
it('should not show system defined badge', async () => {
useTaxonomyDetailData.mockReturnValue({
isSuccess: true,
isFetched: true,
@@ -120,104 +120,10 @@ describe('<TaxonomyDetailPage />', async () => {
id: 1,
name: 'Test taxonomy',
description: 'This is a description',
systemDefined: false,
},
});
const { getByRole, getByText } = render(<RootWrapper />);
// Modal closed
expect(() => getByText('Select format to export')).toThrow();
// Click on export menu
fireEvent.click(getByRole('button'));
fireEvent.click(getByText('Export'));
// Modal opened
expect(getByText('Select format to export')).toBeInTheDocument();
// Click on cancel button
fireEvent.click(getByText('Cancel'));
// Modal closed
expect(() => getByText('Select format to export')).toThrow();
});
it('should open delete dialog on delete menu click', () => {
const taxonomyName = 'Test taxonomy';
useTaxonomyDetailData.mockReturnValue({
isSuccess: true,
isFetched: true,
isError: false,
data: {
id: 1,
name: taxonomyName,
description: 'This is a description',
},
});
const { getByRole, getByText } = render(<RootWrapper />);
// Modal closed
expect(() => getByText(`Delete "${taxonomyName}"`)).toThrow();
// Click on delete menu
fireEvent.click(getByRole('button'));
fireEvent.click(getByText('Delete'));
// Modal opened
expect(getByText(`Delete "${taxonomyName}"`)).toBeInTheDocument();
// Click on cancel button
fireEvent.click(getByText('Cancel'));
// Modal closed
expect(() => getByText(`Delete "${taxonomyName}"`)).toThrow();
});
it('should delete a taxonomy', () => {
const taxonomyName = 'Test taxonomy';
useTaxonomyDetailData.mockReturnValue({
isSuccess: true,
isFetched: true,
isError: false,
data: {
id: 1,
name: taxonomyName,
description: 'This is a description',
},
});
mockMutate.mockImplementationOnce(async (params, callbacks) => {
callbacks.onSuccess();
});
const {
getByRole, getByText, getByLabelText, getByTestId,
} = render(<RootWrapper />);
// Modal closed
expect(() => getByText(`Delete "${taxonomyName}"`)).toThrow();
// Click on delete menu
fireEvent.click(getByRole('button'));
fireEvent.click(getByText('Delete'));
// Modal opened
expect(getByText(`Delete "${taxonomyName}"`)).toBeInTheDocument();
const input = getByLabelText('Type DELETE to confirm');
fireEvent.change(input, { target: { value: 'DELETE' } });
// Click on delete button
fireEvent.click(getByTestId('delete-button'));
// Modal closed
expect(() => getByText(`Delete "${taxonomyName}"`)).toThrow();
expect(mockMutate).toBeCalledTimes(1);
// Should redirect after a success delete
expect(mockSetToastMessage).toBeCalledTimes(1);
expect(mockNavigate).toBeCalledWith('/taxonomies');
const { queryByText } = render(<RootWrapper />);
expect(queryByText('System-level')).not.toBeInTheDocument();
});
});

View File

@@ -14,22 +14,6 @@ const messages = defineMessages({
id: 'course-authoring.taxonomy-detail.side-card.description',
defaultMessage: 'Description',
},
actionsButtonLabel: {
id: 'course-authoring.taxonomy-detail.action.button.label',
defaultMessage: 'Actions',
},
actionsButtonAlt: {
id: 'course-authoring.taxonomy-detail.action.button.alt',
defaultMessage: '{name} actions',
},
exportMenu: {
id: 'course-authoring.taxonomy-detail.action.export',
defaultMessage: 'Export',
},
deleteMenu: {
id: 'course-authoring.taxonomy-detail.action.delete',
defaultMessage: 'Delete',
},
});
export default messages;

View File

@@ -0,0 +1,150 @@
// @ts-check
import React, { useCallback, useContext } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
useToggle,
Button,
Dropdown,
Icon,
IconButton,
} from '@edx/paragon';
import { MoreVert } from '@edx/paragon/icons';
import { omitBy } from 'lodash';
import PropTypes from 'prop-types';
import { useNavigate } from 'react-router-dom';
import ExportModal from '../export-modal';
import { useDeleteTaxonomy } from '../data/apiHooks';
import { TaxonomyContext } from '../common/context';
import DeleteDialog from '../delete-dialog';
import { importTaxonomyTags } from '../import-tags';
import messages from './messages';
const TaxonomyMenu = ({
taxonomy, iconMenu,
}) => {
const intl = useIntl();
const navigate = useNavigate();
const deleteTaxonomy = useDeleteTaxonomy();
const { setToastMessage } = useContext(TaxonomyContext);
const onDeleteTaxonomy = useCallback(() => {
deleteTaxonomy({ pk: taxonomy.id }, {
onSuccess: () => {
if (setToastMessage) {
setToastMessage(intl.formatMessage(messages.taxonomyDeleteToast, { name: taxonomy.name }));
}
navigate('/taxonomies');
},
onError: () => {
// TODO: display the error to the user
},
});
}, [setToastMessage, taxonomy]);
const [isDeleteDialogOpen, deleteDialogOpen, deleteDialogClose] = useToggle(false);
const [isExportModalOpen, exportModalOpen, exportModalClose] = useToggle(false);
/**
* @typedef {Object} MenuItem
* @property {string} title - The title of the menu item
* @property {() => void} action - The action to perform when the menu item is clicked
* @property {boolean} [hide] - Whether or not to hide the menu item
*
* @constant
* @type {Record<string, MenuItem>}
*/
let menuItems = {
import: {
title: intl.formatMessage(messages.importMenu),
action: () => importTaxonomyTags(taxonomy.id, intl),
// Hide import menu item if taxonomy is system defined or allows free text
hide: taxonomy.systemDefined || taxonomy.allowFreeText,
},
export: {
title: intl.formatMessage(messages.exportMenu),
action: exportModalOpen,
},
delete: {
title: intl.formatMessage(messages.deleteMenu),
action: deleteDialogOpen,
// Hide delete menu item if taxonomy is system defined
hide: taxonomy.systemDefined,
},
};
// Remove hidden menu items
menuItems = omitBy(menuItems, (value) => value.hide);
const renderModals = () => (
<>
{isDeleteDialogOpen && (
<DeleteDialog
isOpen={isDeleteDialogOpen}
onClose={deleteDialogClose}
onDelete={onDeleteTaxonomy}
taxonomyName={taxonomy.name}
tagsCount={taxonomy.tagsCount}
/>
)}
{isExportModalOpen && (
<ExportModal
isOpen={isExportModalOpen}
onClose={exportModalClose}
taxonomyId={taxonomy.id}
/>
)}
</>
);
return (
<Dropdown onToggle={(_isOpen, ev) => ev.preventDefault()}>
<Dropdown.Toggle
as={iconMenu ? IconButton : Button}
src={MoreVert}
iconAs={Icon}
variant="primary"
alt={intl.formatMessage(messages.actionsButtonAlt, { name: taxonomy.name })}
data-testid="taxonomy-menu-button"
disabled={Object.keys(menuItems).length === 0}
>
{intl.formatMessage(messages.actionsButtonLabel)}
</Dropdown.Toggle>
<Dropdown.Menu data-testid="taxonomy-menu">
{Object.keys(menuItems).map((key) => (
<Dropdown.Item
key={key}
data-testid={`taxonomy-menu-${key}`}
onClick={
(e) => {
e.preventDefault();
menuItems[key].action();
}
}
>
{menuItems[key].title}
</Dropdown.Item>
))}
</Dropdown.Menu>
{renderModals()}
</Dropdown>
);
};
TaxonomyMenu.propTypes = {
taxonomy: PropTypes.shape({
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
systemDefined: PropTypes.bool.isRequired,
allowFreeText: PropTypes.bool.isRequired,
tagsCount: PropTypes.number.isRequired,
}).isRequired,
iconMenu: PropTypes.bool,
};
TaxonomyMenu.defaultProps = {
iconMenu: false,
};
export default TaxonomyMenu;

View File

@@ -0,0 +1,249 @@
import { useMemo } 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 { render, fireEvent, waitFor } from '@testing-library/react';
import PropTypes from 'prop-types';
import { TaxonomyContext } from '../common/context';
import initializeStore from '../../store';
import { deleteTaxonomy, getTaxonomyExportFile } from '../data/api';
import { importTaxonomyTags } from '../import-tags';
import { TaxonomyMenu } from '.';
let store;
const taxonomyId = 1;
const taxonomyName = 'Taxonomy 1';
jest.mock('../import-tags', () => ({
importTaxonomyTags: jest.fn().mockResolvedValue({}),
}));
jest.mock('../data/api', () => ({
...jest.requireActual('../data/api'),
getTaxonomyExportFile: jest.fn(),
deleteTaxonomy: jest.fn(),
}));
const queryClient = new QueryClient();
const mockSetToastMessage = jest.fn();
const TaxonomyMenuComponent = ({
systemDefined,
allowFreeText,
iconMenu,
}) => {
const context = useMemo(() => ({
toastMessage: null,
setToastMessage: mockSetToastMessage,
}), []);
return (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
<TaxonomyContext.Provider value={context}>
<TaxonomyMenu
taxonomy={{
id: taxonomyId,
name: taxonomyName,
systemDefined,
allowFreeText,
tagsCount: 0,
}}
iconMenu={iconMenu}
/>
</TaxonomyContext.Provider>
</QueryClientProvider>
</IntlProvider>
</AppProvider>
);
};
TaxonomyMenuComponent.propTypes = {
iconMenu: PropTypes.bool.isRequired,
systemDefined: PropTypes.bool,
allowFreeText: PropTypes.bool,
};
TaxonomyMenuComponent.defaultProps = {
systemDefined: false,
allowFreeText: false,
};
describe.each([true, false])('<TaxonomyMenu iconMenu=%s />', async (iconMenu) => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
});
afterEach(() => {
jest.clearAllMocks();
});
test('should open and close menu on button click', () => {
const { getByTestId, queryByTestId } = render(<TaxonomyMenuComponent iconMenu={iconMenu} />);
// Menu closed/doesn't exist yet
expect(queryByTestId('taxonomy-menu')).not.toBeInTheDocument();
// Click on the menu button to open
fireEvent.click(getByTestId('taxonomy-menu-button'));
// Menu opened
expect(getByTestId('taxonomy-menu')).toBeVisible();
// Click on button again to close the menu
fireEvent.click(getByTestId('taxonomy-menu-button'));
// Menu closed
// Jest bug: toBeVisible() isn't checking opacity correctly
// expect(getByTestId('taxonomy-menu')).not.toBeVisible();
expect(getByTestId('taxonomy-menu').style.opacity).toEqual('0');
// Menu button still visible
expect(getByTestId('taxonomy-menu-button')).toBeVisible();
});
test('doesnt show systemDefined taxonomies disabled menus', () => {
const { getByTestId, queryByTestId } = render(<TaxonomyMenuComponent iconMenu={iconMenu} systemDefined />);
// Menu closed/doesn't exist yet
expect(queryByTestId('taxonomy-menu')).not.toBeInTheDocument();
// Click on the menu button to open
fireEvent.click(getByTestId('taxonomy-menu-button'));
// Menu opened
expect(getByTestId('taxonomy-menu')).toBeVisible();
// Check that the import menu is not show
expect(queryByTestId('taxonomy-menu-import')).not.toBeInTheDocument();
});
test('doesnt show freeText taxonomies disabled menus', () => {
const { getByTestId, queryByTestId } = render(<TaxonomyMenuComponent iconMenu={iconMenu} allowFreeText />);
// Menu closed/doesn't exist yet
expect(queryByTestId('taxonomy-menu')).not.toBeInTheDocument();
// Click on the menu button to open
fireEvent.click(getByTestId('taxonomy-menu-button'));
// Menu opened
expect(getByTestId('taxonomy-menu')).toBeVisible();
// Check that the import menu is not show
expect(queryByTestId('taxonomy-menu-import')).not.toBeInTheDocument();
});
test('should open export modal on export menu click', () => {
const { getByTestId, getByText, queryByText } = render(<TaxonomyMenuComponent iconMenu={iconMenu} />);
// Modal closed
expect(queryByText('Select format to export')).not.toBeInTheDocument();
// Click on export menu
fireEvent.click(getByTestId('taxonomy-menu-button'));
fireEvent.click(getByTestId('taxonomy-menu-export'));
// Modal opened
expect(getByText('Select format to export')).toBeInTheDocument();
// Click on cancel button
fireEvent.click(getByText('Cancel'));
// Modal closed
expect(queryByText('Select format to export')).not.toBeInTheDocument();
});
test('should call import tags when menu click', () => {
const { getByTestId } = render(<TaxonomyMenuComponent iconMenu={iconMenu} />);
// Click on import menu
fireEvent.click(getByTestId('taxonomy-menu-button'));
fireEvent.click(getByTestId('taxonomy-menu-import'));
expect(importTaxonomyTags).toHaveBeenCalled();
});
test('should export a taxonomy', () => {
const { getByTestId, getByText, queryByText } = render(<TaxonomyMenuComponent iconMenu={iconMenu} />);
// Click on export menu
fireEvent.click(getByTestId('taxonomy-menu-button'));
fireEvent.click(getByTestId('taxonomy-menu-export'));
// Select JSON format and click on export
fireEvent.click(getByText('JSON file'));
fireEvent.click(getByTestId('export-button-1'));
// Modal closed
expect(queryByText('Select format to export')).not.toBeInTheDocument();
expect(getTaxonomyExportFile).toHaveBeenCalledWith(taxonomyId, 'json');
});
test('should open delete dialog on delete menu click', () => {
const { getByTestId, getByText, queryByText } = render(<TaxonomyMenuComponent iconMenu={iconMenu} />);
// Modal closed
expect(queryByText(`Delete "${taxonomyName}"`)).not.toBeInTheDocument();
// Click on delete menu
fireEvent.click(getByTestId('taxonomy-menu-button'));
fireEvent.click(getByTestId('taxonomy-menu-delete'));
// Modal opened
expect(getByText(`Delete "${taxonomyName}"`)).toBeInTheDocument();
// Click on cancel button
fireEvent.click(getByText('Cancel'));
// Modal closed
expect(queryByText(`Delete "${taxonomyName}"`)).not.toBeInTheDocument();
});
test('should delete a taxonomy', async () => {
const { getByTestId, getByLabelText, queryByText } = render(<TaxonomyMenuComponent iconMenu={iconMenu} />);
// Click on delete menu
fireEvent.click(getByTestId('taxonomy-menu-button'));
fireEvent.click(getByTestId('taxonomy-menu-delete'));
const deleteButton = getByTestId('delete-button');
// The delete button must to be disabled
expect(deleteButton).toBeDisabled();
// Testing delete button enabled/disabled changes
const input = getByLabelText('Type DELETE to confirm');
fireEvent.change(input, { target: { value: 'DELETE_INVALID' } });
expect(deleteButton).toBeDisabled();
fireEvent.change(input, { target: { value: 'DELETE' } });
expect(deleteButton).toBeEnabled();
deleteTaxonomy.mockResolvedValueOnce({});
// Click on delete button
fireEvent.click(deleteButton);
// Modal closed
expect(queryByText(`Delete "${taxonomyName}"`)).not.toBeInTheDocument();
await waitFor(async () => {
expect(deleteTaxonomy).toBeCalledTimes(1);
});
// Toast message shown
expect(mockSetToastMessage).toBeCalledWith(`"${taxonomyName}" deleted`);
});
});

View File

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

View File

@@ -0,0 +1,31 @@
// @ts-check
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
actionsButtonLabel: {
id: 'course-authoring.taxonomy-menu.action.button.label',
defaultMessage: 'Actions',
},
actionsButtonAlt: {
id: 'course-authoring.taxonomy-menu.action.button.alt',
defaultMessage: '{name} actions',
},
importMenu: {
id: 'course-authoring.taxonomy-menu.import.label',
defaultMessage: 'Re-import',
},
exportMenu: {
id: 'course-authoring.taxonomy-menu.export.label',
defaultMessage: 'Export',
},
deleteMenu: {
id: 'course-authoring.taxonomy-menu.delete.label',
defaultMessage: 'Delete',
},
taxonomyDeleteToast: {
id: 'course-authoring.taxonomy-list.toast.delete',
defaultMessage: '"{name}" deleted',
},
});
export default messages;