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:
237
package-lock.json
generated
237
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
2
src/taxonomy/import-tags/__mocks__/index.js
Normal file
2
src/taxonomy/import-tags/__mocks__/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as taxonomyImportMock } from './taxonomyImportMock';
|
||||
export { default as tagImportMock } from './tagImportMock';
|
||||
4
src/taxonomy/import-tags/__mocks__/tagImportMock.js
Normal file
4
src/taxonomy/import-tags/__mocks__/tagImportMock.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export default {
|
||||
name: 'Taxonomy name',
|
||||
description: 'Taxonomy description',
|
||||
};
|
||||
4
src/taxonomy/import-tags/__mocks__/taxonomyImportMock.js
Normal file
4
src/taxonomy/import-tags/__mocks__/taxonomyImportMock.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export default {
|
||||
name: 'Taxonomy name',
|
||||
description: 'Taxonomy description',
|
||||
};
|
||||
58
src/taxonomy/import-tags/data/api.js
Normal file
58
src/taxonomy/import-tags/data/api.js
Normal 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);
|
||||
}
|
||||
48
src/taxonomy/import-tags/data/api.test.js
Normal file
48
src/taxonomy/import-tags/data/api.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
123
src/taxonomy/import-tags/data/utils.js
Normal file
123
src/taxonomy/import-tags/data/utils.js
Normal 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);
|
||||
});
|
||||
};
|
||||
2
src/taxonomy/import-tags/index.js
Normal file
2
src/taxonomy/import-tags/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// @ts-check
|
||||
export { importTaxonomyTags, importTaxonomy } from './data/utils';
|
||||
33
src/taxonomy/import-tags/messages.js
Normal file
33
src/taxonomy/import-tags/messages.js
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
150
src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx
Normal file
150
src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx
Normal 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;
|
||||
249
src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx
Normal file
249
src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx
Normal 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`);
|
||||
});
|
||||
});
|
||||
2
src/taxonomy/taxonomy-menu/index.js
Normal file
2
src/taxonomy/taxonomy-menu/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// @ts-check
|
||||
export { default as TaxonomyMenu } from './TaxonomyMenu'; // eslint-disable-line import/prefer-default-export
|
||||
31
src/taxonomy/taxonomy-menu/messages.js
Normal file
31
src/taxonomy/taxonomy-menu/messages.js
Normal 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;
|
||||
Reference in New Issue
Block a user