diff --git a/package-lock.json b/package-lock.json index cfba25d9d7..0f51334ddd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/src/taxonomy/TaxonomyListPage.jsx b/src/taxonomy/TaxonomyListPage.jsx index e2cf4ac45c..76b9593102 100644 --- a/src/taxonomy/TaxonomyListPage.jsx +++ b/src/taxonomy/TaxonomyListPage.jsx @@ -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 = () => { - @@ -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 = () => { > TaxonomyCard({ ...row, onDeleteTaxonomy })} + CardComponent={(row) => TaxonomyCard(row)} /> )} diff --git a/src/taxonomy/TaxonomyListPage.test.jsx b/src/taxonomy/TaxonomyListPage.test.jsx index fdf581afad..34f55f68f3 100644 --- a/src/taxonomy/TaxonomyListPage.test.jsx +++ b/src/taxonomy/TaxonomyListPage.test.jsx @@ -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 - - - - + + + + {intl.formatMessage(messages.deleteDialogConfirmLabel, { + deleteLabel: {deleteLabel}, + })} + + + + + + + + {intl.formatMessage(messages.deleteDialogCancelLabel)} + + + + + + ); }; diff --git a/src/taxonomy/export-modal/index.jsx b/src/taxonomy/export-modal/index.jsx index ebc8def524..97bf91b43a 100644 --- a/src/taxonomy/export-modal/index.jsx +++ b/src/taxonomy/export-modal/index.jsx @@ -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 ( - - - - {intl.formatMessage(messages.exportModalTitle)} - - - - - - {intl.formatMessage(messages.exportModalBodyDescription)} - - setOutputFormat(e.target.value)} - > - e.stopPropagation() /* This prevents calling onClick handler from the parent */}> + + + + {intl.formatMessage(messages.exportModalTitle)} + + + + + + {intl.formatMessage(messages.exportModalBodyDescription)} + + setOutputFormat(e.target.value)} > - {intl.formatMessage(messages.taxonomyCSVFormat)} - - + {intl.formatMessage(messages.taxonomyCSVFormat)} + + + {intl.formatMessage(messages.taxonomyJSONFormat)} + + + + + + + + {intl.formatMessage(messages.taxonomyModalsCancelLabel)} + + - - - + {intl.formatMessage(messages.exportModalSubmitButtonLabel)} + + + + + ); }; diff --git a/src/taxonomy/import-tags/__mocks__/index.js b/src/taxonomy/import-tags/__mocks__/index.js new file mode 100644 index 0000000000..ba0b48ccb9 --- /dev/null +++ b/src/taxonomy/import-tags/__mocks__/index.js @@ -0,0 +1,2 @@ +export { default as taxonomyImportMock } from './taxonomyImportMock'; +export { default as tagImportMock } from './tagImportMock'; diff --git a/src/taxonomy/import-tags/__mocks__/tagImportMock.js b/src/taxonomy/import-tags/__mocks__/tagImportMock.js new file mode 100644 index 0000000000..9db45b4a5e --- /dev/null +++ b/src/taxonomy/import-tags/__mocks__/tagImportMock.js @@ -0,0 +1,4 @@ +export default { + name: 'Taxonomy name', + description: 'Taxonomy description', +}; diff --git a/src/taxonomy/import-tags/__mocks__/taxonomyImportMock.js b/src/taxonomy/import-tags/__mocks__/taxonomyImportMock.js new file mode 100644 index 0000000000..9db45b4a5e --- /dev/null +++ b/src/taxonomy/import-tags/__mocks__/taxonomyImportMock.js @@ -0,0 +1,4 @@ +export default { + name: 'Taxonomy name', + description: 'Taxonomy description', +}; diff --git a/src/taxonomy/import-tags/data/api.js b/src/taxonomy/import-tags/data/api.js new file mode 100644 index 0000000000..befb2e977d --- /dev/null +++ b/src/taxonomy/import-tags/data/api.js @@ -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} + */ +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} + */ +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); +} diff --git a/src/taxonomy/import-tags/data/api.test.js b/src/taxonomy/import-tags/data/api.test.js new file mode 100644 index 0000000000..0da9f84eae --- /dev/null +++ b/src/taxonomy/import-tags/data/api.test.js @@ -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); + }); +}); diff --git a/src/taxonomy/import-tags/data/utils.js b/src/taxonomy/import-tags/data/utils.js new file mode 100644 index 0000000000..179ca7e816 --- /dev/null +++ b/src/taxonomy/import-tags/data/utils.js @@ -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); + }); +}; diff --git a/src/taxonomy/import-tags/index.js b/src/taxonomy/import-tags/index.js new file mode 100644 index 0000000000..78be9bed95 --- /dev/null +++ b/src/taxonomy/import-tags/index.js @@ -0,0 +1,2 @@ +// @ts-check +export { importTaxonomyTags, importTaxonomy } from './data/utils'; diff --git a/src/taxonomy/import-tags/messages.js b/src/taxonomy/import-tags/messages.js new file mode 100644 index 0000000000..eaa6780d9f --- /dev/null +++ b/src/taxonomy/import-tags/messages.js @@ -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; diff --git a/src/taxonomy/taxonomy-card/TaxonomyCard.scss b/src/taxonomy/taxonomy-card/TaxonomyCard.scss index d124b95e80..6ed11c542b 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCard.scss +++ b/src/taxonomy/taxonomy-card/TaxonomyCard.scss @@ -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; - } } diff --git a/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx b/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx index 4b2b2146dd..6cbbf4c630 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx +++ b/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx @@ -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 }) => ( - + + + ); @@ -65,8 +63,8 @@ describe('', async () => { }); it('not show the system-defined badge with normal taxonomies', () => { - const { getByText } = render(); - expect(() => getByText('System-level')).toThrow(); + const { queryByText } = render(); + expect(queryByText('System-level')).not.toBeInTheDocument(); }); it('shows the system-defined badge with system taxonomies', () => { @@ -80,8 +78,8 @@ describe('', async () => { }); it('not show org count with taxonomies without orgs', () => { - const { getByText } = render(); - expect(() => getByText('Assigned to 0 orgs')).toThrow(); + const { queryByText } = render(); + expect(queryByText('Assigned to 0 orgs')).not.toBeInTheDocument(); }); it('shows org count with taxonomies with orgs', () => { @@ -92,111 +90,4 @@ describe('', async () => { const { getByText } = render(); expect(getByText('Assigned to 6 orgs')).toBeInTheDocument(); }); - - test('should open and close menu on button click', () => { - const { getByTestId } = render(); - - // 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(); - - // 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(); - - // 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(); - - // 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(); - - // 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); - }); }); diff --git a/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx b/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx deleted file mode 100644 index dc4f9b7340..0000000000 --- a/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx +++ /dev/null @@ -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 ( - ev.preventDefault()}> - - - { menuItems.map(item => ( - - {intl.formatMessage(menuMessages[item])} - - ))} - - - ); -}; - -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; diff --git a/src/taxonomy/taxonomy-card/index.jsx b/src/taxonomy/taxonomy-card/index.jsx index 2f640cbf3a..1449730ae5 100644 --- a/src/taxonomy/taxonomy-card/index.jsx +++ b/src/taxonomy/taxonomy-card/index.jsx @@ -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 ( - - ); - }; - const renderExportModal = () => isExportModalOpen && ( - setIsExportModalOpen(false)} - taxonomyId={id} - /> - ); - - const renderDeleteDialog = () => isDeleteDialogOpen && ( - setIsDeleteDialogOpen(false)} - onDelete={onClickDeleteTaxonomy} - taxonomyName={name} - tagsCount={tagsCount} + const getHeaderActions = () => ( + ); return ( - <> - + } + subtitle={( + + )} + actions={getHeaderActions()} + /> + - } - subtitle={( - - )} - actions={getHeaderActions()} - /> - - - {description} - - - - {renderExportModal()} - {renderDeleteDialog()} - + + {description} + + + ); }; @@ -207,7 +150,6 @@ TaxonomyCard.propTypes = { orgsCount: PropTypes.number, tagsCount: PropTypes.number, }).isRequired, - onDeleteTaxonomy: PropTypes.func.isRequired, }; export default TaxonomyCard; diff --git a/src/taxonomy/taxonomy-card/messages.js b/src/taxonomy/taxonomy-card/messages.js index 2b19b9eacc..c6c98fcb11 100644 --- a/src/taxonomy/taxonomy-card/messages.js +++ b/src/taxonomy/taxonomy-card/messages.js @@ -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; diff --git a/src/taxonomy/taxonomy-detail/TaxonomyDetailMenu.jsx b/src/taxonomy/taxonomy-detail/TaxonomyDetailMenu.jsx deleted file mode 100644 index 81da2227a8..0000000000 --- a/src/taxonomy/taxonomy-detail/TaxonomyDetailMenu.jsx +++ /dev/null @@ -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 ( - - { menuItems.map(item => ( - onClickMenuItem(item)} - > - {intl.formatMessage(menuMessages[item])} - - ))} - - ); -}; - -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; diff --git a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx index 3c4ffa8f24..9efd86e42c 100644 --- a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx +++ b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx @@ -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 ( @@ -68,43 +40,12 @@ const TaxonomyDetailPage = () => { ); } - const renderModals = () => isExportModalOpen && ( - setIsExportModalOpen(false)} - taxonomyId={taxonomy.id} - /> - ); - - const renderDeleteDialog = () => isDeleteDialogOpen && ( - setIsDeleteDialogOpen(false)} - onDelete={onClickDeleteTaxonomy} - taxonomyName={taxonomy.name} - tagsCount={0} + const getHeaderActions = () => ( + ); - const onClickMenuItem = (menuName) => ( - menuItemActions[menuName]?.() - ); - - const getHeaderActions = () => { - let enabledMenuItems = menuItems; - if (taxonomy.systemDefined) { - enabledMenuItems = systemDefinedMenuItems; - } - return ( - - ); - }; - const getSystemDefinedBadge = () => { if (taxonomy.systemDefined) { return ; @@ -152,8 +93,6 @@ const TaxonomyDetailPage = () => { - {renderModals()} - {renderDeleteDialog()} ); }; diff --git a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx index 5cd657153b..1f245356b6 100644 --- a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx +++ b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx @@ -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('', 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('', async () => { id: 1, name: 'Test taxonomy', description: 'This is a description', + systemDefined: false, }, }); - - const { getByRole, getByText } = render(); - - // 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(); - - // 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(); - - // 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(); + expect(queryByText('System-level')).not.toBeInTheDocument(); }); }); diff --git a/src/taxonomy/taxonomy-detail/messages.js b/src/taxonomy/taxonomy-detail/messages.js index 6a50e95558..e8ac8851d3 100644 --- a/src/taxonomy/taxonomy-detail/messages.js +++ b/src/taxonomy/taxonomy-detail/messages.js @@ -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; diff --git a/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx b/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx new file mode 100644 index 0000000000..0db273cf46 --- /dev/null +++ b/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx @@ -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} + */ + 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 && ( + + )} + {isExportModalOpen && ( + + )} + + ); + + return ( + ev.preventDefault()}> + + {intl.formatMessage(messages.actionsButtonLabel)} + + + {Object.keys(menuItems).map((key) => ( + { + e.preventDefault(); + menuItems[key].action(); + } + } + > + {menuItems[key].title} + + ))} + + {renderModals()} + + ); +}; + +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; diff --git a/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx b/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx new file mode 100644 index 0000000000..d8a44033ea --- /dev/null +++ b/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx @@ -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 ( + + + + + + + + + + ); +}; + +TaxonomyMenuComponent.propTypes = { + iconMenu: PropTypes.bool.isRequired, + systemDefined: PropTypes.bool, + allowFreeText: PropTypes.bool, +}; + +TaxonomyMenuComponent.defaultProps = { + systemDefined: false, + allowFreeText: false, +}; + +describe.each([true, false])('', 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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`); + }); +}); diff --git a/src/taxonomy/taxonomy-menu/index.js b/src/taxonomy/taxonomy-menu/index.js new file mode 100644 index 0000000000..f22176e8a8 --- /dev/null +++ b/src/taxonomy/taxonomy-menu/index.js @@ -0,0 +1,2 @@ +// @ts-check +export { default as TaxonomyMenu } from './TaxonomyMenu'; // eslint-disable-line import/prefer-default-export diff --git a/src/taxonomy/taxonomy-menu/messages.js b/src/taxonomy/taxonomy-menu/messages.js new file mode 100644 index 0000000000..7d2b105331 --- /dev/null +++ b/src/taxonomy/taxonomy-menu/messages.js @@ -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;