diff --git a/package-lock.json b/package-lock.json index cbf7273cba..0f51334ddd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2763,9 +2763,9 @@ } }, "node_modules/@edx/frontend-lib-content-components": { - "version": "1.177.8", - "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-1.177.8.tgz", - "integrity": "sha512-yQ0TQvxTn4ZzxxYa2CbXCgg0EvApdxdLrfhm1atxG81TH1+AFZO12KOXqLW5ltGrNq9/HHiUyicUC7gcPlKXOA==", + "version": "1.177.9", + "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-1.177.9.tgz", + "integrity": "sha512-BeqYE2rDePTagfyHOjijhZAvTOUhHh7D/I4KTik/0g5+C48VM97xB5Z4ZGxJ/BqzAX6j8JVurqkTMhshCP1u4g==", "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-xml": "^6.0.0", @@ -2781,7 +2781,7 @@ "babel-polyfill": "6.26.0", "codemirror": "^6.0.0", "fast-xml-parser": "^4.0.10", - "frontend-components-tinymce-advanced-plugins": "^1.0.1", + "frontend-components-tinymce-advanced-plugins": "^1.0.2", "lodash-es": "^4.17.21", "moment": "^2.29.4", "moment-shortformat": "^2.1.0", @@ -13694,9 +13694,9 @@ } }, "node_modules/frontend-components-tinymce-advanced-plugins": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/frontend-components-tinymce-advanced-plugins/-/frontend-components-tinymce-advanced-plugins-1.0.1.tgz", - "integrity": "sha512-PeHbWqWXicE+yxRN0Ir9dfbIXb3bzawZo8+xS83vNrWExbZ9K7BYShZ2yxXAY0pvXDmqmXu9JmIHC/RF1fpgYw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/frontend-components-tinymce-advanced-plugins/-/frontend-components-tinymce-advanced-plugins-1.0.2.tgz", + "integrity": "sha512-QlSe4Pcv6UGx5Cg3NAstWN4lUgSQb3rWrJOjto28BgnSzpqQgqBoQ68U7vZxJFARY98Qj7AisKri0urMOVX/Rg==", "dependencies": { "@edx/frontend-build": "12.8.27", "tinymce": "^5.10.4" @@ -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/CourseAuthoringPage.jsx b/src/CourseAuthoringPage.jsx index b11dcc7948..3e6b50f2e3 100644 --- a/src/CourseAuthoringPage.jsx +++ b/src/CourseAuthoringPage.jsx @@ -9,6 +9,7 @@ import { StudioFooter } from '@edx/frontend-component-footer'; import Header from './header'; import { fetchCourseDetail } from './data/thunks'; import { useModel } from './generic/model-store'; +import NotFoundAlert from './generic/NotFoundAlert'; import PermissionDeniedAlert from './generic/PermissionDeniedAlert'; import { getCourseAppsApiStatus } from './pages-and-resources/data/selectors'; import { RequestStatus } from './data/constants'; @@ -50,10 +51,16 @@ const CourseAuthoringPage = ({ courseId, children }) => { const courseOrg = courseDetail ? courseDetail.org : null; const courseTitle = courseDetail ? courseDetail.name : courseId; const courseAppsApiStatus = useSelector(getCourseAppsApiStatus); - const inProgress = useSelector(state => state.courseDetail.status) === RequestStatus.IN_PROGRESS; + const courseDetailStatus = useSelector(state => state.courseDetail.status); + const inProgress = courseDetailStatus === RequestStatus.IN_PROGRESS; const { pathname } = useLocation(); const showHeader = !pathname.includes('/editor'); + if (courseDetailStatus === RequestStatus.NOT_FOUND) { + return ( + + ); + } if (courseAppsApiStatus === RequestStatus.DENIED) { return ( diff --git a/src/CourseAuthoringPage.test.jsx b/src/CourseAuthoringPage.test.jsx index 3e982c5929..c7eeeb9be8 100644 --- a/src/CourseAuthoringPage.test.jsx +++ b/src/CourseAuthoringPage.test.jsx @@ -12,6 +12,7 @@ import CourseAuthoringPage from './CourseAuthoringPage'; import PagesAndResources from './pages-and-resources/PagesAndResources'; import { executeThunk } from './utils'; import { fetchCourseApps } from './pages-and-resources/data/thunks'; +import { fetchCourseDetail } from './data/thunks'; const courseId = 'course-v1:edX+TestX+Test_Course'; let mockPathname = '/evilguy/'; @@ -24,6 +25,19 @@ jest.mock('react-router-dom', () => ({ let axiosMock; let store; +beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); +}); + describe('Editor Pages Load no header', () => { const mockStoreSuccess = async () => { const apiBaseUrl = getConfig().STUDIO_BASE_URL; @@ -33,18 +47,6 @@ describe('Editor Pages Load no header', () => { }); await executeThunk(fetchCourseApps(courseId), store.dispatch); }; - beforeEach(() => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - store = initializeStore(); - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - }); test('renders no loading wheel on editor pages', async () => { mockPathname = '/editor/'; await mockStoreSuccess(); @@ -76,3 +78,56 @@ describe('Editor Pages Load no header', () => { expect(wrapper.queryByRole('status')).toBeInTheDocument(); }); }); + +describe('Course authoring page', () => { + const lmsApiBaseUrl = getConfig().LMS_BASE_URL; + const courseDetailApiUrl = `${lmsApiBaseUrl}/api/courses/v1/courses`; + const mockStoreNotFound = async () => { + axiosMock.onGet( + `${courseDetailApiUrl}/${courseId}?username=abc123`, + ).reply(404, { + response: { status: 404 }, + }); + await executeThunk(fetchCourseDetail(courseId), store.dispatch); + }; + const mockStoreError = async () => { + axiosMock.onGet( + `${courseDetailApiUrl}/${courseId}?username=abc123`, + ).reply(500, { + response: { status: 500 }, + }); + await executeThunk(fetchCourseDetail(courseId), store.dispatch); + }; + test('renders not found page on non-existent course key', async () => { + await mockStoreNotFound(); + const wrapper = render( + + + + + + , + ); + expect(await wrapper.findByTestId('notFoundAlert')).toBeInTheDocument(); + }); + test('does not render not found page on other kinds of error', async () => { + await mockStoreError(); + // Currently, loading errors are not handled, so we wait for the child + // content to be rendered -which happens when request status is no longer + // IN_PROGRESS but also not NOT_FOUND or DENIED- then check that the not + // found alert is not present. + const contentTestId = 'courseAuthoringPageContent'; + const wrapper = render( + + + +
+ + + + , + ); + expect(await wrapper.findByTestId(contentTestId)).toBeInTheDocument(); + expect(wrapper.queryByTestId('notFoundAlert')).not.toBeInTheDocument(); + }); +}); diff --git a/src/content-tags-drawer/data/api.js b/src/content-tags-drawer/data/api.js index 5bd30772be..b5b28514f9 100644 --- a/src/content-tags-drawer/data/api.js +++ b/src/content-tags-drawer/data/api.js @@ -29,7 +29,8 @@ export const getTaxonomyTagsApiUrl = (taxonomyId, options = {}) => { return url.href; }; export const getContentTaxonomyTagsApiUrl = (contentId) => new URL(`api/content_tagging/v1/object_tags/${contentId}/`, getApiBaseUrl()).href; -export const getContentDataApiUrl = (contentId) => new URL(`/xblock/outline/${contentId}`, getApiBaseUrl()).href; +export const getXBlockContentDataApiURL = (contentId) => new URL(`/xblock/outline/${contentId}`, getApiBaseUrl()).href; +export const getLibraryContentDataApiUrl = (contentId) => new URL(`/api/libraries/v2/blocks/${contentId}/`, getApiBaseUrl()).href; /** * Get all tags that belong to taxonomy. @@ -59,7 +60,10 @@ export async function getContentTaxonomyTagsData(contentId) { * @returns {Promise} */ export async function getContentData(contentId) { - const { data } = await getAuthenticatedHttpClient().get(getContentDataApiUrl(contentId)); + const url = contentId.startsWith('lb:') + ? getLibraryContentDataApiUrl(contentId) + : getXBlockContentDataApiURL(contentId); + const { data } = await getAuthenticatedHttpClient().get(url); return camelCaseObject(data); } diff --git a/src/content-tags-drawer/data/api.test.js b/src/content-tags-drawer/data/api.test.js index b007e12c96..d183f59ea2 100644 --- a/src/content-tags-drawer/data/api.test.js +++ b/src/content-tags-drawer/data/api.test.js @@ -13,7 +13,8 @@ import { import { getTaxonomyTagsApiUrl, getContentTaxonomyTagsApiUrl, - getContentDataApiUrl, + getXBlockContentDataApiURL, + getLibraryContentDataApiUrl, getTaxonomyTagsData, getContentTaxonomyTagsData, getContentData, @@ -87,12 +88,21 @@ describe('content tags drawer api calls', () => { expect(result).toEqual(contentTaxonomyTagsMock[contentId]); }); - it('should get content data', async () => { + it('should get content data for course component', async () => { const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b'; - axiosMock.onGet(getContentDataApiUrl(contentId)).reply(200, contentDataMock); + axiosMock.onGet(getXBlockContentDataApiURL(contentId)).reply(200, contentDataMock); const result = await getContentData(contentId); - expect(axiosMock.history.get[0].url).toEqual(getContentDataApiUrl(contentId)); + expect(axiosMock.history.get[0].url).toEqual(getXBlockContentDataApiURL(contentId)); + expect(result).toEqual(contentDataMock); + }); + + it('should get content data for V2 library component', async () => { + const contentId = 'lb:SampleTaxonomyOrg1:NTL1:html:a3eded6b-2106-429a-98be-63533d563d79'; + axiosMock.onGet(getLibraryContentDataApiUrl(contentId)).reply(200, contentDataMock); + const result = await getContentData(contentId); + + expect(axiosMock.history.get[0].url).toEqual(getLibraryContentDataApiUrl(contentId)); expect(result).toEqual(contentDataMock); }); diff --git a/src/data/constants.js b/src/data/constants.js index 5191ea1dfa..bd01f09dda 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -13,6 +13,7 @@ export const RequestStatus = { PENDING: 'pending', CLEAR: 'clear', PARTIAL: 'partial', + NOT_FOUND: 'not-found', }; /** diff --git a/src/data/thunks.js b/src/data/thunks.js index 9a52d4d89d..9c797dc6a5 100644 --- a/src/data/thunks.js +++ b/src/data/thunks.js @@ -21,7 +21,11 @@ export function fetchCourseDetail(courseId) { canChangeProviders: getAuthenticatedUser().administrator || new Date(courseDetail.start) > new Date(), })); } catch (error) { - dispatch(updateStatus({ courseId, status: RequestStatus.FAILED })); + if (error.response && error.response.status === 404) { + dispatch(updateStatus({ courseId, status: RequestStatus.NOT_FOUND })); + } else { + dispatch(updateStatus({ courseId, status: RequestStatus.FAILED })); + } } }; } diff --git a/src/generic/NotFoundAlert.jsx b/src/generic/NotFoundAlert.jsx new file mode 100644 index 0000000000..8ff9cf4fff --- /dev/null +++ b/src/generic/NotFoundAlert.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Alert } from '@edx/paragon'; + +const NotFoundAlert = () => ( + + + +); + +export default NotFoundAlert; diff --git a/src/generic/data/api.js b/src/generic/data/api.js index 7257fb689b..c00e302efd 100644 --- a/src/generic/data/api.js +++ b/src/generic/data/api.js @@ -1,3 +1,4 @@ +// @ts-check import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; @@ -9,8 +10,8 @@ export const getCourseRerunUrl = (courseId) => new URL(`/api/contentstore/v1/cou export const getOrganizationsUrl = () => new URL('organizations', getApiBaseUrl()).href; /** - * Get's organizations data. - * @returns {Promise} + * Get's organizations data. Returns list of organization names. + * @returns {Promise} */ export async function getOrganizations() { const { data } = await getAuthenticatedHttpClient().get( @@ -32,7 +33,7 @@ export async function getCourseRerun(courseId) { /** * Create or rerun course with data. - * @param {object} data + * @param {object} courseData * @returns {Promise} */ export async function createOrRerunCourse(courseData) { diff --git a/src/generic/data/apiHooks.js b/src/generic/data/apiHooks.js new file mode 100644 index 0000000000..5640878b20 --- /dev/null +++ b/src/generic/data/apiHooks.js @@ -0,0 +1,15 @@ +// @ts-check +import { useQuery } from '@tanstack/react-query'; +import { getOrganizations } from './api'; + +/** + * Builds the query to get a list of available organizations + */ +export const useOrganizationListData = () => ( + useQuery({ + queryKey: ['organizationList'], + queryFn: () => getOrganizations(), + }) +); + +export default useOrganizationListData; diff --git a/src/import-page/data/api.js b/src/import-page/data/api.js index dda69970ea..1955dc98d4 100644 --- a/src/import-page/data/api.js +++ b/src/import-page/data/api.js @@ -12,13 +12,12 @@ export const getImportStatusApiUrl = (courseId, fileName) => `${getApiBaseUrl()} * @param {Object} requestConfig * @returns {Promise} */ -export async function startCourseImporting(courseId, fileData, requestConfig) { +export async function startCourseImporting(courseId, fileData, requestConfig, updateProgress) { const chunkSize = 20 * 1000000; // 20 MB const fileSize = fileData.size || 0; const chunkLength = Math.ceil(fileSize / chunkSize); let resp; - - const upload = async (blob, start, stop) => { + const upload = async (blob, start, stop, index) => { const contentRange = `bytes ${start}-${stop}/${fileSize}`; const contentDisposition = `attachment; filename="${fileData.name}"`; const headers = { @@ -33,6 +32,8 @@ export async function startCourseImporting(courseId, fileData, requestConfig) { formData, { headers, ...requestConfig }, ); + const percent = Math.trunc(((1 / chunkLength) * (index + 1)) * 100); + updateProgress(percent); resp = camelCaseObject(data); }; @@ -40,7 +41,7 @@ export async function startCourseImporting(courseId, fileData, requestConfig) { const start = index * chunkSize; const stop = start + chunkSize < fileSize ? start + chunkSize : fileSize; const blob = file.slice(start, stop, file.type); - await upload(blob, start, stop - 1); + await upload(blob, start, stop - 1, index); }; /* eslint-disable no-await-in-loop */ diff --git a/src/import-page/data/api.test.jsx b/src/import-page/data/api.test.jsx index 9aa50dac5f..5f0e7b43b8 100644 --- a/src/import-page/data/api.test.jsx +++ b/src/import-page/data/api.test.jsx @@ -29,7 +29,7 @@ describe('API Functions', () => { const data = { importStatus: 1 }; axiosMock.onPost(postImportCourseApiUrl(courseId)).reply(200, data); - const result = await startCourseImporting(courseId, file); + const result = await startCourseImporting(courseId, file, {}, jest.fn()); expect(axiosMock.history.post[0].url).toEqual(postImportCourseApiUrl(courseId)); expect(result).toEqual(data); }); diff --git a/src/import-page/data/thunks.js b/src/import-page/data/thunks.js index 04415443f1..95acb291ec 100644 --- a/src/import-page/data/thunks.js +++ b/src/import-page/data/thunks.js @@ -6,7 +6,7 @@ import { RequestStatus } from '../../data/constants'; import { setImportCookie } from '../utils'; import { getImportStatus, startCourseImporting } from './api'; import { - reset, updateCurrentStage, updateError, updateFileName, + reset, updateCurrentStage, updateError, updateFileName, updateProgress, updateImportTriggered, updateLoadingStatus, updateSavingStatus, updateSuccessDate, } from './slice'; import { IMPORT_STAGES, LAST_IMPORT_COOKIE_NAME } from './constants'; @@ -44,9 +44,14 @@ export function handleProcessUpload(courseId, fileData, requestConfig, handleErr const file = fileData.get('file'); dispatch(reset()); dispatch(updateSavingStatus(RequestStatus.PENDING)); - dispatch(updateImportTriggered(true)); dispatch(updateFileName(file.name)); - const { importStatus } = await startCourseImporting(courseId, file, requestConfig); + dispatch(updateImportTriggered(true)); + const { importStatus } = await startCourseImporting( + courseId, + file, + requestConfig, + (percent) => dispatch(updateProgress(percent)), + ); dispatch(updateCurrentStage(importStatus)); setImportCookie(moment().valueOf(), importStatus === IMPORT_STAGES.SUCCESS, file.name); dispatch(updateSavingStatus(RequestStatus.SUCCESSFUL)); diff --git a/src/import-page/file-section/FileSection.jsx b/src/import-page/file-section/FileSection.jsx index b601268af2..012023f04f 100644 --- a/src/import-page/file-section/FileSection.jsx +++ b/src/import-page/file-section/FileSection.jsx @@ -11,7 +11,6 @@ import { IMPORT_STAGES } from '../data/constants'; import { getCurrentStage, getError, getFileName, getImportTriggered, } from '../data/selectors'; -import { updateProgress } from '../data/slice'; import messages from './messages'; import { handleProcessUpload } from '../data/thunks'; @@ -42,7 +41,6 @@ const FileSection = ({ intl, courseId }) => { handleError, )) } - onUploadProgress={(percent) => dispatch(updateProgress(percent))} accept={{ 'application/gzip': ['.tar.gz'] }} data-testid="dropzone" /> diff --git a/src/import-page/import-stepper/ImportStepper.jsx b/src/import-page/import-stepper/ImportStepper.jsx index 72755d825f..476187b294 100644 --- a/src/import-page/import-stepper/ImportStepper.jsx +++ b/src/import-page/import-stepper/ImportStepper.jsx @@ -95,7 +95,7 @@ const ImportStepper = ({ intl, courseId }) => {

{intl.formatMessage(messages.stepperHeaderTitle)}

({ allowAnonymousPosts: false, allowAnonymousPostsPeers: false, reportedContentEmailNotifications: false, - enableReportedContentEmailNotifications: false, allowDivisionByUnit: false, restrictedDates: [], cohortsEnabled: false, @@ -141,7 +140,6 @@ describe('OpenedXConfigForm', () => { ...legacyApiResponse, plugin_configuration: { ...legacyApiResponse.plugin_configuration, - reported_content_email_notifications_flag: true, divided_course_wide_discussions: [], available_division_schemes: [], }, @@ -181,7 +179,6 @@ describe('OpenedXConfigForm', () => { ...legacyApiResponse.plugin_configuration, allow_anonymous: true, reported_content_email_notifications: true, - reported_content_email_notifications_flag: true, always_divide_inline_discussions: true, divided_course_wide_discussions: [], available_division_schemes: ['cohorts'], @@ -222,7 +219,6 @@ describe('OpenedXConfigForm', () => { ...legacyApiResponse.plugin_configuration, allow_anonymous: true, reported_content_email_notifications: true, - reported_content_email_notifications_flag: true, always_divide_inline_discussions: true, divided_course_wide_discussions: ['13f106c6-6735-4e84-b097-0456cff55960', 'course'], }, diff --git a/src/pages-and-resources/discussions/app-config-form/apps/shared/ReportedContentEmailNotifications.jsx b/src/pages-and-resources/discussions/app-config-form/apps/shared/ReportedContentEmailNotifications.jsx index 08972f60e8..67fc81896a 100644 --- a/src/pages-and-resources/discussions/app-config-form/apps/shared/ReportedContentEmailNotifications.jsx +++ b/src/pages-and-resources/discussions/app-config-form/apps/shared/ReportedContentEmailNotifications.jsx @@ -13,24 +13,19 @@ const ReportedContentEmailNotifications = ({ intl }) => { } = useFormikContext(); return ( - // eslint-disable-next-line react/jsx-no-useless-fragment - <> - {values.enableReportedContentEmailNotifications && ( -
-
{intl.formatMessage(messages.reportedContentEmailNotifications)}
- - -
- )} - +
+
{intl.formatMessage(messages.reportedContentEmailNotifications)}
+ + +
); }; diff --git a/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/DiscussionTopics.test.jsx b/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/DiscussionTopics.test.jsx index 6f9181c2af..7ab9384b82 100644 --- a/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/DiscussionTopics.test.jsx +++ b/src/pages-and-resources/discussions/app-config-form/apps/shared/discussion-topics/DiscussionTopics.test.jsx @@ -33,7 +33,6 @@ const appConfig = { allowAnonymousPosts: false, allowAnonymousPostsPeers: false, reportedContentEmailNotifications: false, - enableReportedContentEmailNotifications: false, allowDivisionByUnit: false, restrictedDates: [], }; diff --git a/src/pages-and-resources/discussions/data/api.js b/src/pages-and-resources/discussions/data/api.js index 12d0177f87..959457adb2 100644 --- a/src/pages-and-resources/discussions/data/api.js +++ b/src/pages-and-resources/discussions/data/api.js @@ -63,7 +63,6 @@ function normalizePluginConfig(data) { allowAnonymousPosts: data.allow_anonymous, allowAnonymousPostsPeers: data.allow_anonymous_to_peers, reportedContentEmailNotifications: data.reported_content_email_notifications, - enableReportedContentEmailNotifications: data.reported_content_email_notifications_flag, divisionScheme: data.division_scheme, alwaysDivideInlineDiscussions: data.always_divide_inline_discussions, restrictedDates: normalizeRestrictedDates(data.discussion_blackouts), diff --git a/src/pages-and-resources/discussions/data/redux.test.js b/src/pages-and-resources/discussions/data/redux.test.js index ac383c405f..7e8f9fea5a 100644 --- a/src/pages-and-resources/discussions/data/redux.test.js +++ b/src/pages-and-resources/discussions/data/redux.test.js @@ -245,7 +245,6 @@ describe('Data layer integration tests', () => { allowAnonymousPosts: false, allowAnonymousPostsPeers: false, reportedContentEmailNotifications: false, - enableReportedContentEmailNotifications: false, restrictedDates: [], // TODO: Note! As of this writing, all the data below this line is NOT returned in the API // but we add it in during normalization. diff --git a/src/pages-and-resources/discussions/factories/mockApiResponses.js b/src/pages-and-resources/discussions/factories/mockApiResponses.js index e16814935d..e8ce839eff 100644 --- a/src/pages-and-resources/discussions/factories/mockApiResponses.js +++ b/src/pages-and-resources/discussions/factories/mockApiResponses.js @@ -110,7 +110,6 @@ export const generateLegacyApiResponse = () => ({ allow_anonymous: false, allow_anonymous_to_peers: false, reported_content_email_notifications: false, - reported_content_email_notifications_flag: false, always_divide_inline_discussions: false, available_division_schemes: ['enrollment_track'], discussion_topics: { diff --git a/src/schedule-and-details/pacing-section/PacingSection.test.jsx b/src/schedule-and-details/pacing-section/PacingSection.test.jsx index 30f4fe83cf..c8bacc17f1 100644 --- a/src/schedule-and-details/pacing-section/PacingSection.test.jsx +++ b/src/schedule-and-details/pacing-section/PacingSection.test.jsx @@ -43,7 +43,7 @@ describe('', () => { }); it('shows disabled radio inputs correctly', () => { - const pastDate = '2023-12-31'; + const pastDate = '2024-12-31'; const initialProps = { ...props, startDate: pastDate }; const { getAllByRole, queryAllByText } = render( , diff --git a/src/taxonomy/TaxonomyListPage.jsx b/src/taxonomy/TaxonomyListPage.jsx index c80a393326..4e77b12641 100644 --- a/src/taxonomy/TaxonomyListPage.jsx +++ b/src/taxonomy/TaxonomyListPage.jsx @@ -1,4 +1,5 @@ -import React, { useContext } from 'react'; +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; import { Button, CardView, @@ -8,19 +9,28 @@ import { OverlayTrigger, Spinner, Tooltip, + SelectMenu, + MenuItem, } from '@edx/paragon'; import { Add, + Check, } 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 { TaxonomyContext } from './common/context'; + +const ALL_TAXONOMIES = 'All taxonomies'; +const UNASSIGNED = 'Unassigned'; const TaxonomyListHeaderButtons = () => { const intl = useIntl(); @@ -57,40 +67,105 @@ const TaxonomyListHeaderButtons = () => { - ); }; +const OrganizationFilterSelector = ({ + isOrganizationListLoaded, + organizationListData, + selectedOrgFilter, + setSelectedOrgFilter, +}) => { + const intl = useIntl(); + const isOrgSelected = (value) => (value === selectedOrgFilter ? : null); + const selectOptions = [ + isOrgSelected(ALL_TAXONOMIES)} + onClick={() => setSelectedOrgFilter(ALL_TAXONOMIES)} + > + { isOrgSelected(ALL_TAXONOMIES) + ? intl.formatMessage(messages.orgInputSelectDefaultValue) + : intl.formatMessage(messages.orgAllValue)} + , + isOrgSelected(UNASSIGNED)} + onClick={() => setSelectedOrgFilter(UNASSIGNED)} + > + { intl.formatMessage(messages.orgUnassignedValue) } + , + ]; + + if (isOrganizationListLoaded && organizationListData) { + organizationListData.forEach(org => ( + selectOptions.push( + isOrgSelected(org)} + onClick={() => setSelectedOrgFilter(org)} + > + {org} + , + ) + )); + } + + return ( + + { isOrganizationListLoaded + ? selectOptions + : ( + + )} + + ); +}; + 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, + } = useOrganizationListData(); const useTaxonomyListData = () => { - const taxonomyListData = useTaxonomyListDataResponse(); - const isLoaded = useIsTaxonomyListDataLoaded(); + const taxonomyListData = useTaxonomyListDataResponse(selectedOrgFilter); + const isLoaded = useIsTaxonomyListDataLoaded(selectedOrgFilter); return { taxonomyListData, isLoaded }; }; const { taxonomyListData, isLoaded } = useTaxonomyListData(); const getOrgSelect = () => ( - // Organization select component - // TODO Add functionality to this component - undefined + // Initialize organization select component + ); return ( @@ -139,7 +214,7 @@ const TaxonomyListPage = () => { > TaxonomyCard({ ...row, onDeleteTaxonomy })} + CardComponent={(row) => TaxonomyCard(row)} /> )} @@ -158,6 +233,13 @@ const TaxonomyListPage = () => { ); }; +OrganizationFilterSelector.propTypes = { + isOrganizationListLoaded: PropTypes.bool.isRequired, + organizationListData: PropTypes.arrayOf(PropTypes.string).isRequired, + selectedOrgFilter: PropTypes.string.isRequired, + setSelectedOrgFilter: PropTypes.func.isRequired, +}; + TaxonomyListPage.propTypes = {}; export default TaxonomyListPage; diff --git a/src/taxonomy/TaxonomyListPage.scss b/src/taxonomy/TaxonomyListPage.scss new file mode 100644 index 0000000000..b501e8a847 --- /dev/null +++ b/src/taxonomy/TaxonomyListPage.scss @@ -0,0 +1,7 @@ +.taxonomy-orgs-filter-selector { + // Without this, the default bold styling for the focused option + // in the org select menu is too thick + .pgn__menu-item:focus { + font-weight: bold; + } +} diff --git a/src/taxonomy/TaxonomyListPage.test.jsx b/src/taxonomy/TaxonomyListPage.test.jsx index c7a634d290..00a960543d 100644 --- a/src/taxonomy/TaxonomyListPage.test.jsx +++ b/src/taxonomy/TaxonomyListPage.test.jsx @@ -1,52 +1,60 @@ -import React, { useMemo } from 'react'; +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; -const mockSetToastMessage = jest.fn(); -const mockDeleteTaxonomy = jest.fn(); +let axiosMock; + const taxonomies = [{ id: 1, name: 'Taxonomy', description: 'This is a description', }]; +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/data/utils.test.js b/src/taxonomy/import-tags/data/utils.test.js new file mode 100644 index 0000000000..ddcc029410 --- /dev/null +++ b/src/taxonomy/import-tags/data/utils.test.js @@ -0,0 +1,301 @@ +import { importTaxonomy, importTaxonomyTags } from './utils'; +import { importNewTaxonomy, importTags } from './api'; + +const mockAddEventListener = jest.fn(); + +const intl = { + formatMessage: jest.fn().mockImplementation((message) => message.defaultMessage), +}; + +jest.mock('./api', () => ({ + importNewTaxonomy: jest.fn().mockResolvedValue({}), + importTags: jest.fn().mockResolvedValue({}), +})); + +describe('import new taxonomy functions', () => { + let createElement; + let appendChild; + let removeChild; + + beforeEach(() => { + createElement = document.createElement; + document.createElement = jest.fn().mockImplementation((element) => { + if (element === 'input') { + return { + click: jest.fn(), + addEventListener: mockAddEventListener, + style: {}, + }; + } + return createElement(element); + }); + + appendChild = document.body.appendChild; + document.body.appendChild = jest.fn(); + + removeChild = document.body.removeChild; + document.body.removeChild = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + document.createElement = createElement; + document.body.appendChild = appendChild; + document.body.removeChild = removeChild; + }); + + describe('import new taxonomy', () => { + it('should call the api and show success alert', async () => { + jest.spyOn(window, 'prompt') + .mockReturnValueOnce('test taxonomy name') + .mockReturnValueOnce('test taxonomy description'); + jest.spyOn(window, 'alert').mockImplementation(() => {}); + + const promise = importTaxonomy(intl).then(() => { + expect(importNewTaxonomy).toHaveBeenCalledWith('test taxonomy name', 'test taxonomy description', 'mockFile'); + expect(window.alert).toHaveBeenCalledWith('Taxonomy imported successfully'); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [ + 'mockFile', + ], + }, + }; + + onChange(mockTarget); + + return promise; + }); + + it('should ask for taxonomy name again if not provided', async () => { + jest.spyOn(window, 'prompt') + .mockReturnValueOnce('') + .mockReturnValueOnce('test taxonomy name') + .mockReturnValueOnce('test taxonomy description'); + jest.spyOn(window, 'alert').mockImplementation(() => {}); + + const promise = importTaxonomy(intl).then(() => { + expect(importNewTaxonomy).toHaveBeenCalledWith('test taxonomy name', 'test taxonomy description', 'mockFile'); + expect(window.alert).toHaveBeenCalledWith('You must enter a name for the new taxonomy'); + expect(window.alert).toHaveBeenCalledWith('Taxonomy imported successfully'); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [ + 'mockFile', + ], + }, + }; + + onChange(mockTarget); + + return promise; + }); + + it('should call the api and return error alert', async () => { + jest.spyOn(window, 'prompt') + .mockReturnValueOnce('test taxonomy name') + .mockReturnValueOnce('test taxonomy description'); + importNewTaxonomy.mockRejectedValue(new Error('test error')); + + const promise = importTaxonomy(intl).then(() => { + expect(importNewTaxonomy).toHaveBeenCalledWith('test taxonomy name', 'test taxonomy description', 'mockFile'); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [ + 'mockFile', + ], + }, + }; + + onChange(mockTarget); + + return promise; + }); + + it('should abort the call to the api without file', async () => { + const promise = importTaxonomy(intl).then(() => { + expect(importNewTaxonomy).not.toHaveBeenCalled(); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [null], + }, + }; + + onChange(mockTarget); + return promise; + }); + + it('should abort the call to the api if file closed', async () => { + const promise = importTaxonomy(intl).then(() => { + expect(importNewTaxonomy).not.toHaveBeenCalled(); + }); + + // Capture the onCancel handler from the file input element + const onCancel = mockAddEventListener.mock.calls[1][1]; + + onCancel(); + return promise; + }); + + it('should abort the call to the api when cancel name prompt', async () => { + jest.spyOn(window, 'prompt').mockReturnValueOnce(null); + + const promise = importTaxonomy(intl).then(() => { + expect(importNewTaxonomy).not.toHaveBeenCalled(); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [ + 'mockFile', + ], + }, + }; + + onChange(mockTarget); + + return promise; + }); + + it('should abort the call to the api when cancel description prompt', async () => { + jest.spyOn(window, 'prompt') + .mockReturnValueOnce('test taxonomy name') + .mockReturnValueOnce(null); + + const promise = importTaxonomy(intl).then(() => { + expect(importNewTaxonomy).not.toHaveBeenCalled(); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [ + 'mockFile', + ], + }, + }; + + onChange(mockTarget); + return promise; + }); + }); + + describe('import tags', () => { + it('should call the api and show success alert', async () => { + jest.spyOn(window, 'confirm').mockReturnValueOnce(true); + jest.spyOn(window, 'alert').mockImplementation(() => {}); + + const promise = importTaxonomyTags(1, intl).then(() => { + expect(importTags).toHaveBeenCalledWith(1, 'mockFile'); + expect(window.alert).toHaveBeenCalledWith('Taxonomy imported successfully'); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [ + 'mockFile', + ], + }, + }; + + onChange(mockTarget); + + return promise; + }); + + it('should abort the call to the api without file', async () => { + const promise = importTaxonomyTags(1, intl).then(() => { + expect(importTags).not.toHaveBeenCalled(); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [null], + }, + }; + + onChange(mockTarget); + return promise; + }); + + it('should abort the call to the api if file closed', async () => { + const promise = importTaxonomyTags(1, intl).then(() => { + expect(importTags).not.toHaveBeenCalled(); + }); + + // Capture the onCancel handler from the file input element + const onCancel = mockAddEventListener.mock.calls[1][1]; + + onCancel(); + return promise; + }); + + it('should abort the call to the api when cancel the confirm dialog', async () => { + jest.spyOn(window, 'confirm').mockReturnValueOnce(null); + + const promise = importTaxonomyTags(1, intl).then(() => { + expect(importTags).not.toHaveBeenCalled(); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [ + 'mockFile', + ], + }, + }; + + onChange(mockTarget); + + return promise; + }); + + it('should call the api and return error alert', async () => { + jest.spyOn(window, 'confirm').mockReturnValueOnce(true); + importTags.mockRejectedValue(new Error('test error')); + + const promise = importTaxonomyTags(1, intl).then(() => { + expect(importTags).toHaveBeenCalledWith(1, 'mockFile'); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [ + 'mockFile', + ], + }, + }; + + onChange(mockTarget); + + return promise; + }); + }); +}); 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/index.scss b/src/taxonomy/index.scss index 3655a35bcc..13642488ec 100644 --- a/src/taxonomy/index.scss +++ b/src/taxonomy/index.scss @@ -1,3 +1,4 @@ +@import "taxonomy/TaxonomyListPage"; @import "taxonomy/taxonomy-card/TaxonomyCard"; @import "taxonomy/delete-dialog/DeleteDialog"; @import "taxonomy/system-defined-badge/SystemDefinedBadge"; diff --git a/src/taxonomy/manage-orgs/ManageOrgsModal.jsx b/src/taxonomy/manage-orgs/ManageOrgsModal.jsx new file mode 100644 index 0000000000..383c0f55b6 --- /dev/null +++ b/src/taxonomy/manage-orgs/ManageOrgsModal.jsx @@ -0,0 +1,231 @@ +// @ts-check +import React, { useContext, useEffect, useState } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + useToggle, + ActionRow, + AlertModal, + Button, + Chip, + Container, + Form, + ModalDialog, + Stack, +} from '@edx/paragon'; +import { + Close, + Warning, +} from '@edx/paragon/icons'; +import PropTypes from 'prop-types'; + +import { useOrganizationListData } from '../../generic/data/apiHooks'; +import { TaxonomyContext } from '../common/context'; +import { useTaxonomyDetailDataResponse } from '../data/apiHooks'; +import { useManageOrgs } from './data/api'; +import messages from './messages'; +import './ManageOrgsModal.scss'; + +const ConfirmModal = ({ + isOpen, + onClose, + confirm, + taxonomyName, +}) => { + const intl = useIntl(); + return ( + + + + + )} + > +

+ {intl.formatMessage(messages.confirmUnassignText, { taxonomyName })} +

+
+ ); +}; + +ConfirmModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + confirm: PropTypes.func.isRequired, + taxonomyName: PropTypes.string.isRequired, +}; + +const ManageOrgsModal = ({ + taxonomyId, + isOpen, + onClose, +}) => { + const intl = useIntl(); + const { setToastMessage } = useContext(TaxonomyContext); + + const [selectedOrgs, setSelectedOrgs] = useState(/** @type {null|string[]} */(null)); + const [allOrgs, setAllOrgs] = useState(/** @type {null|boolean} */(null)); + + const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false); + + const { + data: organizationListData, + } = useOrganizationListData(); + + const taxonomy = useTaxonomyDetailDataResponse(taxonomyId); + + const manageOrgMutation = useManageOrgs(); + + const saveOrgs = async () => { + closeConfirmModal(); + if (selectedOrgs !== null && allOrgs !== null) { + try { + await manageOrgMutation.mutateAsync({ + taxonomyId, + orgs: allOrgs ? undefined : selectedOrgs, + allOrgs, + }); + if (setToastMessage) { + setToastMessage(intl.formatMessage(messages.assignOrgsSuccess)); + } + } catch (/** @type {any} */ error) { + // ToDo: display the error to the user + } finally { + onClose(); + } + } + }; + + const confirmSave = async () => { + if (!selectedOrgs?.length && !allOrgs) { + openConfirmModal(); + } else { + await saveOrgs(); + } + }; + + useEffect(() => { + if (taxonomy) { + if (selectedOrgs === null) { + setSelectedOrgs([...taxonomy.orgs]); + } + if (allOrgs === null) { + setAllOrgs(taxonomy.allOrgs); + } + } + }, [taxonomy]); + + useEffect(() => { + if (selectedOrgs) { + // This is a hack to force the Form.Autosuggest to clear its value after a selection is made. + const inputRef = /** @type {null|HTMLInputElement} */ (document.querySelector('.pgn__form-group input')); + if (inputRef) { + // @ts-ignore + inputRef.value = null; + const event = new Event('change', { bubbles: true }); + inputRef.dispatchEvent(event); + } + } + }, [selectedOrgs]); + + if (!selectedOrgs || !taxonomy) { + return null; + } + + return ( + e.stopPropagation() /* This prevents calling onClick handler from the parent */}> + + + + {intl.formatMessage(messages.headerTitle)} + + + +
+ + + + +
{intl.formatMessage(messages.bodyText)}
+
{intl.formatMessage(messages.currentAssignments)}
+
+ {selectedOrgs.length ? selectedOrgs.map((org) => ( + setSelectedOrgs(selectedOrgs.filter((o) => o !== org))} + disabled={allOrgs} + > + {org} + + )) : {intl.formatMessage(messages.noOrganizationAssigned)} } +
+
+
+ + + {intl.formatMessage(messages.addOrganizations)} + + setSelectedOrgs([...selectedOrgs, org])} + disabled={allOrgs} + > + {organizationListData ? organizationListData.filter(o => !selectedOrgs?.includes(o)).map((org) => ( + {org} + )) : [] } + + + setAllOrgs(e.target.checked)}> + {intl.formatMessage(messages.assignAll)} + +
+ +
+ + + + + {intl.formatMessage(messages.cancelButton)} + + + + +
+ +
+ ); +}; + +ManageOrgsModal.propTypes = { + taxonomyId: PropTypes.number.isRequired, + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, +}; + +export default ManageOrgsModal; diff --git a/src/taxonomy/manage-orgs/ManageOrgsModal.scss b/src/taxonomy/manage-orgs/ManageOrgsModal.scss new file mode 100644 index 0000000000..4ae418ece0 --- /dev/null +++ b/src/taxonomy/manage-orgs/ManageOrgsModal.scss @@ -0,0 +1,13 @@ +.manage-orgs { + /* + This style is needed to override the default overflow: scroll on the modal, + preventing the dropdown to overflow the modal. + This is being fixed here: + https://github.com/openedx/paragon/pull/2939 + */ + overflow: visible !important; + + .pgn__modal-body { + overflow: visible; + } +} diff --git a/src/taxonomy/manage-orgs/ManageOrgsModal.test.jsx b/src/taxonomy/manage-orgs/ManageOrgsModal.test.jsx new file mode 100644 index 0000000000..29e10729db --- /dev/null +++ b/src/taxonomy/manage-orgs/ManageOrgsModal.test.jsx @@ -0,0 +1,207 @@ +import React from 'react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + fireEvent, + render, + waitFor, +} from '@testing-library/react'; +import PropTypes from 'prop-types'; + +import initializeStore from '../../store'; +import { TaxonomyContext } from '../common/context'; +import ManageOrgsModal from './ManageOrgsModal'; + +let store; + +const taxonomy = { + id: 1, + name: 'Test Taxonomy', + allOrgs: false, + orgs: ['org1', 'org2'], +}; + +const orgs = ['org1', 'org2', 'org3', 'org4', 'org5']; + +jest.mock('../data/api', () => ({ + ...jest.requireActual('../data/api'), + getTaxonomy: jest.fn().mockResolvedValue(taxonomy), +})); + +jest.mock('../../generic/data/api', () => ({ + ...jest.requireActual('../../generic/data/api'), + getOrganizations: jest.fn().mockResolvedValue(orgs), +})); + +const mockUseManageOrgsMutate = jest.fn(); + +jest.mock('./data/api', () => ({ + ...jest.requireActual('./data/api'), + useManageOrgs: jest.fn(() => ({ + ...jest.requireActual('./data/api').useManageOrgs(), + mutateAsync: mockUseManageOrgsMutate, + })), +})); + +const mockSetToastMessage = jest.fn(); +const mockSetAlertProps = jest.fn(); +const context = { + toastMessage: null, + setToastMessage: mockSetToastMessage, + alertProps: null, + setAlertProps: mockSetAlertProps, +}; + +const queryClient = new QueryClient(); + +const RootWrapper = ({ onClose }) => ( + + + + + + + + + +); + +RootWrapper.propTypes = { + onClose: PropTypes.func.isRequired, +}; + +describe('', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const checkDialogRender = async (getByText) => { + await waitFor(() => { + // Dialog title + expect(getByText('Assign to organizations')).toBeInTheDocument(); + // Orgs assigned to the taxonomy + expect(getByText('org1')).toBeInTheDocument(); + expect(getByText('org2')).toBeInTheDocument(); + }); + }; + + it('should render the dialog and close on cancel', async () => { + const onClose = jest.fn(); + const { getByText, getByRole } = render(); + + await checkDialogRender(getByText); + + const cancelButton = getByRole('button', { name: 'Cancel' }); + fireEvent.click(cancelButton); + + expect(onClose).toHaveBeenCalled(); + }); + + it('can assign orgs to taxonomies from the dialog', async () => { + const onClose = jest.fn(); + const { + queryAllByTestId, + getByTestId, + getByText, + } = render(); + + await checkDialogRender(getByText); + + // Remove org2 + fireEvent.click(getByText('org2').nextSibling); + + const input = getByTestId('autosuggest-iconbutton'); + fireEvent.click(input); + + const list = queryAllByTestId('autosuggest-optionitem'); + expect(list.length).toBe(4); // Show org3, org4, org5 + expect(getByText('org2')).toBeInTheDocument(); + expect(getByText('org3')).toBeInTheDocument(); + expect(getByText('org4')).toBeInTheDocument(); + expect(getByText('org5')).toBeInTheDocument(); + + // Select org3 + fireEvent.click(list[1]); + + fireEvent.click(getByTestId('save-button')); + + await waitFor(() => { + expect(mockUseManageOrgsMutate).toHaveBeenCalledWith({ + taxonomyId: taxonomy.id, + orgs: ['org1', 'org3'], + allOrgs: false, + }); + }); + + // Toast message shown + expect(mockSetToastMessage).toBeCalledWith('Assigned organizations updated'); + }); + + it('can assign all orgs to taxonomies from the dialog', async () => { + const onClose = jest.fn(); + const { getByRole, getByTestId, getByText } = render(); + + await checkDialogRender(getByText); + + const checkbox = getByRole('checkbox', { name: 'Assign to all organizations' }); + fireEvent.click(checkbox); + + fireEvent.click(getByTestId('save-button')); + + await waitFor(() => { + expect(mockUseManageOrgsMutate).toHaveBeenCalledWith({ + taxonomyId: taxonomy.id, + allOrgs: true, + }); + }); + + // Toast message shown + expect(mockSetToastMessage).toBeCalledWith('Assigned organizations updated'); + }); + + it('can assign no orgs to taxonomies from the dialog', async () => { + const onClose = jest.fn(); + const { getByRole, getByTestId, getByText } = render(); + + await checkDialogRender(getByText); + + // Remove org1 + fireEvent.click(getByText('org1').nextSibling); + // Remove org2 + fireEvent.click(getByText('org2').nextSibling); + + fireEvent.click(getByTestId('save-button')); + + await waitFor(() => { + // Check confirm modal is open + expect(getByText('Unassign taxonomy')).toBeInTheDocument(); + }); + + fireEvent.click(getByRole('button', { name: 'Continue' })); + + await waitFor(() => { + expect(mockUseManageOrgsMutate).toHaveBeenCalledWith({ + taxonomyId: taxonomy.id, + allOrgs: false, + orgs: [], + }); + }); + + // Toast message shown + expect(mockSetToastMessage).toBeCalledWith('Assigned organizations updated'); + }); +}); diff --git a/src/taxonomy/manage-orgs/data/api.js b/src/taxonomy/manage-orgs/data/api.js new file mode 100644 index 0000000000..499aa3c731 --- /dev/null +++ b/src/taxonomy/manage-orgs/data/api.js @@ -0,0 +1,54 @@ +// @ts-check +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { useQueryClient, useMutation } from '@tanstack/react-query'; + +const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; + +/** + * @param {number} taxonomyId + * @returns {string} + */ +export const getManageOrgsApiUrl = (taxonomyId) => new URL( + `api/content_tagging/v1/taxonomies/${taxonomyId}/orgs/`, + getApiBaseUrl(), +).href; + +/** + * Build the mutation to assign organizations to a taxonomy. + */ +export const useManageOrgs = () => { + const queryClient = useQueryClient(); + return useMutation({ + /** + * @type {import("@tanstack/react-query").MutateFunction< + * any, + * any, + * { + * taxonomyId: number, + * orgs?: string[], + * allOrgs: boolean, + * } + * >} + */ + mutationFn: async ({ taxonomyId, orgs, allOrgs }) => { + const { data } = await getAuthenticatedHttpClient().put( + getManageOrgsApiUrl(taxonomyId), + { + all_orgs: allOrgs, + orgs: allOrgs ? undefined : orgs, + }, + ); + + return camelCaseObject(data); + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: ['taxonomyList'], + }); + queryClient.invalidateQueries({ + queryKey: ['taxonomyDetail', variables.taxonomyId], + }); + }, + }); +}; diff --git a/src/taxonomy/manage-orgs/data/api.test.jsx b/src/taxonomy/manage-orgs/data/api.test.jsx new file mode 100644 index 0000000000..40f047f6e6 --- /dev/null +++ b/src/taxonomy/manage-orgs/data/api.test.jsx @@ -0,0 +1,81 @@ +import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { renderHook } from '@testing-library/react-hooks'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import MockAdapter from 'axios-mock-adapter'; + +import { + getManageOrgsApiUrl, + useManageOrgs, + +} from './api'; + +let axiosMock; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +const wrapper = ({ children }) => ( + + {children} + +); + +describe('import taxonomy api calls', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should call update taxonomy orgs', async () => { + axiosMock.onPut(getManageOrgsApiUrl(1)).reply(200); + const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useManageOrgs(), { wrapper }); + + await result.current.mutateAsync({ taxonomyId: 1, orgs: ['org1', 'org2'], allOrgs: false }); + expect(axiosMock.history.put[0].url).toEqual(getManageOrgsApiUrl(1)); + expect(axiosMock.history.put[0].data).toEqual(JSON.stringify({ all_orgs: false, orgs: ['org1', 'org2'] })); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['taxonomyList'], + }); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['taxonomyDetail', 1], + }); + }); + + it('should call update taxonomy orgs with allOrgs', async () => { + axiosMock.onPut(getManageOrgsApiUrl(1)).reply(200); + const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useManageOrgs(), { wrapper }); + + await result.current.mutateAsync({ taxonomyId: 1, orgs: ['org1', 'org2'], allOrgs: true }); + expect(axiosMock.history.put[0].url).toEqual(getManageOrgsApiUrl(1)); + // Should not send orgs when allOrgs is true + expect(axiosMock.history.put[0].data).toEqual(JSON.stringify({ all_orgs: true })); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['taxonomyList'], + }); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['taxonomyDetail', 1], + }); + }); +}); diff --git a/src/taxonomy/manage-orgs/index.js b/src/taxonomy/manage-orgs/index.js new file mode 100644 index 0000000000..9006d2be41 --- /dev/null +++ b/src/taxonomy/manage-orgs/index.js @@ -0,0 +1 @@ +export { default as ManageOrgsModal } from './ManageOrgsModal'; // eslint-disable-line import/prefer-default-export diff --git a/src/taxonomy/manage-orgs/messages.js b/src/taxonomy/manage-orgs/messages.js new file mode 100644 index 0000000000..3bfeafe585 --- /dev/null +++ b/src/taxonomy/manage-orgs/messages.js @@ -0,0 +1,65 @@ +// @ts-check +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + headerTitle: { + id: 'course-authoring.taxonomy-manage-orgs.header.title', + defaultMessage: 'Assign to organizations', + }, + bodyText: { + id: 'course-authoring.taxonomy-manage-orgs.body.text', + defaultMessage: 'Manage which organizations can access the taxonomy by assigning them in the menu below. You can ' + + 'also choose to assign the taxonomy to all organizations.', + }, + assignOrgs: { + id: 'course-authoring.taxonomy-manage-orgs.assign-orgs', + defaultMessage: 'Assign organizations', + }, + currentAssignments: { + id: 'course-authoring.taxonomy-manage-orgs.current-assignments', + defaultMessage: 'Currently assigned:', + }, + addOrganizations: { + id: 'course-authoring.taxonomy-manage-orgs.add-orgs', + defaultMessage: 'Add another organization:', + }, + searchOrganizations: { + id: 'course-authoring.taxonomy-manage-orgs.search-orgs', + defaultMessage: 'Search for an organization', + }, + noOrganizationAssigned: { + id: 'course-authoring.taxonomy-manage-orgs.no-orgs', + defaultMessage: 'No organizations assigned', + }, + assignAll: { + id: 'course-authoring.taxonomy-manage-orgs.assign-all', + defaultMessage: 'Assign to all organizations', + }, + cancelButton: { + id: 'course-authoring.taxonomy-manage-orgs.button.cancel', + defaultMessage: 'Cancel', + }, + saveButton: { + id: 'course-authoring.taxonomy-manage-orgs.button.save', + defaultMessage: 'Save', + }, + confirmUnassignTitle: { + id: 'course-authoring.taxonomy-manage-orgs.confirm-dialog.title', + defaultMessage: 'Unassign taxonomy', + }, + confirmUnassignText: { + id: 'course-authoring.taxonomy-manage-orgs.confirm-dialog.text', + defaultMessage: 'Content authors from unassigned organizations will not be able to tag course content with ' + + '{taxonomyName}. Are you sure you want to continue?', + }, + continueButton: { + id: 'course-authoring.taxonomy-manage-orgs.confirm-dialog.button.continue', + defaultMessage: 'Continue', + }, + assignOrgsSuccess: { + id: 'course-authoring.taxonomy-manage-orgs.toast.assign-orgs-success', + defaultMessage: 'Assigned organizations updated', + }, +}); + +export default messages; diff --git a/src/taxonomy/messages.js b/src/taxonomy/messages.js index ace08d33dc..7987eb525d 100644 --- a/src/taxonomy/messages.js +++ b/src/taxonomy/messages.js @@ -29,6 +29,14 @@ const messages = defineMessages({ id: 'course-authoring.taxonomy-list.select.org.default', defaultMessage: 'All taxonomies', }, + orgAllValue: { + id: 'course-authoring.taxonomy-list.select.org.all', + defaultMessage: 'All', + }, + orgUnassignedValue: { + id: 'course-authoring.taxonomy-list.select.org.unassigned', + defaultMessage: 'Unassigned', + }, usageLoadingMessage: { id: 'course-authoring.taxonomy-list.spinner.loading', defaultMessage: 'Loading', 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..486d7ac165 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 { useTaxonomyDetailDataResponse, useTaxonomyDetailDataStatus } from './data/apiHooks'; -import DeleteDialog from '../delete-dialog'; -import { useDeleteTaxonomy } from '../data/apiHooks'; -import { TaxonomyContext } from '../common/context'; +import { TaxonomyMenu } from '../taxonomy-menu'; +import TaxonomyDetailSideCard from './TaxonomyDetailSideCard'; +import { useTaxonomyDetailDataResponse, useTaxonomyDetailDataStatus } from '../data/apiHooks'; 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..d161d56c12 100644 --- a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx +++ b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx @@ -1,22 +1,17 @@ -import React, { useMemo } from 'react'; +import React 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 { useTaxonomyDetailDataResponse, useTaxonomyDetailDataStatus } from '../data/apiHooks'; import initializeStore from '../../store'; import TaxonomyDetailPage from './TaxonomyDetailPage'; -import { TaxonomyContext } from '../common/context'; let store; const mockNavigate = jest.fn(); const mockMutate = jest.fn(); -const mockSetToastMessage = jest.fn(); -jest.mock('./data/api', () => ({ - useTaxonomyDetailData: jest.fn(), -})); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), // use actual for all non-hook parts useParams: () => ({ @@ -25,31 +20,25 @@ jest.mock('react-router-dom', () => ({ useNavigate: () => mockNavigate, })); jest.mock('../data/apiHooks', () => ({ + ...jest.requireActual('../data/apiHooks'), useDeleteTaxonomy: () => mockMutate, + useTaxonomyDetailDataResponse: jest.fn(), + useTaxonomyDetailDataStatus: jest.fn(), })); jest.mock('./TaxonomyDetailSideCard', () => jest.fn(() => <>Mock TaxonomyDetailSideCard)); jest.mock('../tag-list/TagListTable', () => jest.fn(() => <>Mock TagListTable)); -const RootWrapper = () => { - const context = useMemo(() => ({ - toastMessage: null, - setToastMessage: mockSetToastMessage, - }), []); - - return ( - - - - - - - - ); -}; +const RootWrapper = () => ( + + + + + +); -describe('', async () => { - beforeEach(async () => { +describe('', () => { + beforeEach(() => { initializeMockApp({ authenticatedUser: { userId: 3, @@ -61,8 +50,8 @@ describe('', async () => { store = initializeStore(); }); - it('shows the spinner before the query is complete', async () => { - useTaxonomyDetailData.mockReturnValue({ + it('shows the spinner before the query is complete', () => { + useTaxonomyDetailDataStatus.mockReturnValue({ isFetched: false, }); const { getByRole } = render(); @@ -71,48 +60,48 @@ describe('', async () => { }); it('shows the connector error component if got some error', async () => { - useTaxonomyDetailData.mockReturnValue({ + useTaxonomyDetailDataStatus.mockReturnValue({ isFetched: true, isError: true, }); const { getByTestId } = render(); + expect(getByTestId('connectionErrorAlert')).toBeInTheDocument(); }); - it('should render page and page title correctly', async () => { - useTaxonomyDetailData.mockReturnValue({ + it('should render page and page title correctly', () => { + useTaxonomyDetailDataStatus.mockReturnValue({ isSuccess: true, isFetched: true, isError: false, - data: { - id: 1, - name: 'Test taxonomy', - description: 'This is a description', - systemDefined: true, - }, + }); + useTaxonomyDetailDataResponse.mockReturnValue({ + id: 1, + name: 'Test taxonomy', + description: 'This is a description', }); const { getByRole } = render(); expect(getByRole('heading')).toHaveTextContent('Test taxonomy'); }); it('should show system defined badge', async () => { - useTaxonomyDetailData.mockReturnValue({ + useTaxonomyDetailDataStatus.mockReturnValue({ isSuccess: true, isFetched: true, isError: false, - data: { - id: 1, - name: 'Test taxonomy', - description: 'This is a description', - systemDefined: true, - }, + }); + useTaxonomyDetailDataResponse.mockReturnValue({ + id: 1, + name: 'Test taxonomy', + description: 'This is a description', + systemDefined: true, }); const { getByText } = render(); expect(getByText('System-level')).toBeInTheDocument(); }); - it('should open export modal on export menu click', () => { - useTaxonomyDetailData.mockReturnValue({ + it('should not show system defined badge', async () => { + useTaxonomyDetailDataResponse.mockReturnValue({ isSuccess: true, isFetched: true, isError: false, @@ -120,104 +109,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/data/api.js b/src/taxonomy/taxonomy-detail/data/api.js deleted file mode 100644 index 3cc9126059..0000000000 --- a/src/taxonomy/taxonomy-detail/data/api.js +++ /dev/null @@ -1,28 +0,0 @@ -// @ts-check - -// TODO: this file needs to be merged into src/taxonomy/data/api.js -// We are creating a mess with so many different /data/[api|types].js files in subfolders. -// There is only one tagging/taxonomy API, and it should be implemented via a single types.mjs and api.js file. - -import { camelCaseObject, getConfig } from '@edx/frontend-platform'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { useQuery } from '@tanstack/react-query'; - -const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; -const getTaxonomyDetailApiUrl = (taxonomyId) => new URL( - `api/content_tagging/v1/taxonomies/${taxonomyId}/`, - getApiBaseUrl(), -).href; - -/** - * @param {number} taxonomyId - * @returns {import('@tanstack/react-query').UseQueryResult} - */ // eslint-disable-next-line import/prefer-default-export -export const useTaxonomyDetailData = (taxonomyId) => ( - useQuery({ - queryKey: ['taxonomyDetail', taxonomyId], - queryFn: () => getAuthenticatedHttpClient().get(getTaxonomyDetailApiUrl(taxonomyId)) - .then((response) => response.data) - .then(camelCaseObject), - }) -); diff --git a/src/taxonomy/taxonomy-detail/data/api.test.js b/src/taxonomy/taxonomy-detail/data/api.test.js deleted file mode 100644 index 257421680c..0000000000 --- a/src/taxonomy/taxonomy-detail/data/api.test.js +++ /dev/null @@ -1,27 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { - useTaxonomyDetailData, -} from './api'; - -const mockHttpClient = { - get: jest.fn(), -}; - -jest.mock('@tanstack/react-query', () => ({ - useQuery: jest.fn(), -})); - -jest.mock('@edx/frontend-platform/auth', () => ({ - getAuthenticatedHttpClient: jest.fn(() => mockHttpClient), -})); - -describe('useTaxonomyDetailData', () => { - it('should call useQuery with the correct parameters', () => { - useTaxonomyDetailData('1'); - - expect(useQuery).toHaveBeenCalledWith({ - queryKey: ['taxonomyDetail', '1'], - queryFn: expect.any(Function), - }); - }); -}); diff --git a/src/taxonomy/taxonomy-detail/data/apiHooks.jsx b/src/taxonomy/taxonomy-detail/data/apiHooks.jsx deleted file mode 100644 index 7a06b33618..0000000000 --- a/src/taxonomy/taxonomy-detail/data/apiHooks.jsx +++ /dev/null @@ -1,36 +0,0 @@ -// @ts-check -import { - useTaxonomyDetailData, -} from './api'; - -/** - * @param {number} taxonomyId - * @returns {Pick} - */ -export const useTaxonomyDetailDataStatus = (taxonomyId) => { - const { - isError, - error, - isFetched, - isSuccess, - } = useTaxonomyDetailData(taxonomyId); - return { - isError, - error, - isFetched, - isSuccess, - }; -}; - -/** - * @param {number} taxonomyId - * @returns {import("../../data/types.mjs").TaxonomyData | undefined} - */ -export const useTaxonomyDetailDataResponse = (taxonomyId) => { - const { isSuccess, data } = useTaxonomyDetailData(taxonomyId); - if (isSuccess) { - return data; - } - - return undefined; -}; diff --git a/src/taxonomy/taxonomy-detail/data/apiHooks.test.jsx b/src/taxonomy/taxonomy-detail/data/apiHooks.test.jsx deleted file mode 100644 index e69232c363..0000000000 --- a/src/taxonomy/taxonomy-detail/data/apiHooks.test.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { - useTaxonomyDetailDataStatus, - useTaxonomyDetailDataResponse, -} from './apiHooks'; - -jest.mock('@tanstack/react-query', () => ({ - useQuery: jest.fn(), -})); - -describe('useTaxonomyDetailDataStatus', () => { - it('should return status values', () => { - const status = { - isError: false, - error: undefined, - isFetched: true, - isSuccess: true, - }; - - useQuery.mockReturnValueOnce(status); - - const result = useTaxonomyDetailDataStatus(0); - - expect(result).toEqual(status); - }); -}); - -describe('useTaxonomyDetailDataResponse', () => { - it('should return data when status is success', () => { - useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' }); - - const result = useTaxonomyDetailDataResponse(); - - expect(result).toEqual('data'); - }); - - it('should return undefined when status is not success', () => { - useQuery.mockReturnValueOnce({ isSuccess: false }); - - const result = useTaxonomyDetailDataResponse(); - - expect(result).toBeUndefined(); - }); -}); diff --git a/src/taxonomy/taxonomy-detail/index.js b/src/taxonomy/taxonomy-detail/index.js index 5665033c97..8d14e0d50d 100644 --- a/src/taxonomy/taxonomy-detail/index.js +++ b/src/taxonomy/taxonomy-detail/index.js @@ -1,2 +1,2 @@ -// ts-check +// @ts-check export { default as TaxonomyDetailPage } from './TaxonomyDetailPage'; // eslint-disable-line import/prefer-default-export 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..730bc796e0 --- /dev/null +++ b/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx @@ -0,0 +1,165 @@ +// @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 { ManageOrgsModal } from '../manage-orgs'; +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); + const [isManageOrgsModalOpen, manageOrgsModalOpen, manageOrgsModalClose] = 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, + }, + manageOrgs: { + title: intl.formatMessage(messages.manageOrgsMenu), + action: manageOrgsModalOpen, + // Hide import menu item if taxonomy is system defined + hide: taxonomy.systemDefined, + }, + }; + + // Remove hidden menu items + menuItems = omitBy(menuItems, (value) => value.hide); + + const renderModals = () => ( + <> + {isDeleteDialogOpen && ( + + )} + {isExportModalOpen && ( + + )} + {isManageOrgsModalOpen && ( + + )} + + ); + + 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..5a9619a62b --- /dev/null +++ b/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx @@ -0,0 +1,278 @@ +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, getTaxonomy, 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(), + getTaxonomy: 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(); + expect(queryByTestId('taxonomy-menu-manageOrgs')).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`); + }); + + it('should open manage orgs dialog menu click', async () => { + const { getByTestId, getByText, queryByText } = render(); + // We need to provide a taxonomy or the modal will not open + getTaxonomy.mockResolvedValue({ + id: 1, + name: 'Taxonomy 1', + orgs: [], + allOrgs: true, + }); + + // Modal closed + expect(queryByText('Assign to organizations')).not.toBeInTheDocument(); + + // Click on delete menu + fireEvent.click(getByTestId('taxonomy-menu-button')); + fireEvent.click(getByTestId('taxonomy-menu-manageOrgs')); + + // Modal opened + await waitFor(() => expect(getByText('Assign to organizations')).toBeInTheDocument()); + + // Click on cancel button + fireEvent.click(getByText('Cancel')); + + // Modal closed + expect(() => getByText('Assign to organizations')).toThrow(); + }); +}); 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..3a71118ccb --- /dev/null +++ b/src/taxonomy/taxonomy-menu/messages.js @@ -0,0 +1,35 @@ +// @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', + }, + manageOrgsMenu: { + id: 'course-authoring.taxonomy-menu.assign-orgs.label', + defaultMessage: 'Manage Organizations', + }, + 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;