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/assets/scss/_utilities.scss b/src/assets/scss/_utilities.scss index 6e74efb452..1cdaf84bab 100644 --- a/src/assets/scss/_utilities.scss +++ b/src/assets/scss/_utilities.scss @@ -2,6 +2,10 @@ color: $black; } +.h-200px { + height: 200px; +} + .mw-300px { max-width: 300px; } 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/files-and-videos/files-page/FileInfoModalSidebar.jsx b/src/files-and-videos/files-page/FileInfoModalSidebar.jsx index 842c6e568c..e2a92b40c5 100644 --- a/src/files-and-videos/files-page/FileInfoModalSidebar.jsx +++ b/src/files-and-videos/files-page/FileInfoModalSidebar.jsx @@ -18,7 +18,7 @@ import { } from '@edx/paragon'; import { ContentCopy, InfoOutline } from '@edx/paragon/icons'; -import { getFileSizeToClosestByte } from '../generic/utils'; +import { getFileSizeToClosestByte } from '../../utils'; import messages from './messages'; const FileInfoModalSidebar = ({ diff --git a/src/files-and-videos/files-page/FilesPage.jsx b/src/files-and-videos/files-page/FilesPage.jsx index 236e594a92..55b0ee23e2 100644 --- a/src/files-and-videos/files-page/FilesPage.jsx +++ b/src/files-and-videos/files-page/FilesPage.jsx @@ -27,7 +27,7 @@ import { FileTable, ThumbnailColumn, } from '../generic'; -import { getFileSizeToClosestByte } from '../generic/utils'; +import { getFileSizeToClosestByte } from '../../utils'; import FileThumbnail from './FileThumbnail'; import FileInfoModalSidebar from './FileInfoModalSidebar'; diff --git a/src/files-and-videos/generic/utils.js b/src/files-and-videos/generic/utils.js index 7cbcc07d4c..e23afc7012 100644 --- a/src/files-and-videos/generic/utils.js +++ b/src/files-and-videos/generic/utils.js @@ -1,23 +1,4 @@ -export const getFileSizeToClosestByte = (fileSize, numberOfDivides = 0) => { - if (fileSize > 1000) { - const updatedSize = fileSize / 1000; - const incrementNumberOfDivides = numberOfDivides + 1; - return getFileSizeToClosestByte(updatedSize, incrementNumberOfDivides); - } - const fileSizeFixedDecimal = Number.parseFloat(fileSize).toFixed(2); - switch (numberOfDivides) { - case 1: - return `${fileSizeFixedDecimal} KB`; - case 2: - return `${fileSizeFixedDecimal} MB`; - case 3: - return `${fileSizeFixedDecimal} GB`; - default: - return `${fileSizeFixedDecimal} B`; - } -}; - -export const sortFiles = (files, sortType) => { +export const sortFiles = (files, sortType) => { // eslint-disable-line import/prefer-default-export const [sort, direction] = sortType.split(','); let sortedFiles; if (sort === 'displayName') { diff --git a/src/files-and-videos/videos-page/info-sidebar/InfoTab.jsx b/src/files-and-videos/videos-page/info-sidebar/InfoTab.jsx index cdc5ab2bd6..cef932d573 100644 --- a/src/files-and-videos/videos-page/info-sidebar/InfoTab.jsx +++ b/src/files-and-videos/videos-page/info-sidebar/InfoTab.jsx @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Stack } from '@edx/paragon'; import { injectIntl, FormattedDate, FormattedMessage } from '@edx/frontend-platform/i18n'; -import { getFileSizeToClosestByte } from '../../generic/utils'; +import { getFileSizeToClosestByte } from '../../../utils'; import { getFormattedDuration } from '../data/utils'; import messages from './messages'; 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/generic/loading-button/LoadingButton.test.jsx b/src/generic/loading-button/LoadingButton.test.jsx new file mode 100644 index 0000000000..5a50e82c20 --- /dev/null +++ b/src/generic/loading-button/LoadingButton.test.jsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import LoadingButton from '.'; + +const buttonTitle = 'Button Title'; + +const RootWrapper = (onClick) => ( + + {buttonTitle} + +); + +describe('', () => { + it('renders the title and doesnt handle the spinner initially', () => { + const { getByText, getByTestId } = render(RootWrapper(() => { })); + const titleElement = getByText(buttonTitle); + expect(titleElement).toBeInTheDocument(); + expect(() => getByTestId('button-loading-spinner')).toThrow('Unable to find an element'); + }); + + it('doesnt render the spinner initially without onClick function', () => { + const { getByRole, getByText, getByTestId } = render(RootWrapper()); + const titleElement = getByText(buttonTitle); + expect(titleElement).toBeInTheDocument(); + expect(() => getByTestId('button-loading-spinner')).toThrow('Unable to find an element'); + const buttonElement = getByRole('button'); + buttonElement.click(); + expect(() => getByTestId('button-loading-spinner')).toThrow('Unable to find an element'); + }); + + it('renders the spinner correctly', () => { + const longFunction = () => new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + const { getByRole, getByText, getByTestId } = render(RootWrapper(longFunction)); + const buttonElement = getByRole('button'); + buttonElement.click(); + const spinnerElement = getByTestId('button-loading-spinner'); + expect(spinnerElement).toBeInTheDocument(); + const titleElement = getByText(buttonTitle); + expect(titleElement).toBeInTheDocument(); + expect(buttonElement).toBeDisabled(); + setTimeout(() => { + expect(buttonElement).toBeEnabled(); + expect(spinnerElement).not.toBeInTheDocument(); + }, 2000); + }); + + it('renders the spinner correctly even with error', () => { + const longFunction = () => new Promise((_resolve, reject) => { + setTimeout(reject, 1000); + }); + const { getByRole, getByText, getByTestId } = render(RootWrapper(longFunction)); + const buttonElement = getByRole('button'); + buttonElement.click(); + const spinnerElement = getByTestId('button-loading-spinner'); + expect(spinnerElement).toBeInTheDocument(); + const titleElement = getByText(buttonTitle); + expect(titleElement).toBeInTheDocument(); + expect(buttonElement).toBeDisabled(); + setTimeout(() => { + expect(buttonElement).toBeEnabled(); + expect(spinnerElement).not.toBeInTheDocument(); + }, 2000); + }); +}); diff --git a/src/generic/loading-button/index.jsx b/src/generic/loading-button/index.jsx new file mode 100644 index 0000000000..55e88cad97 --- /dev/null +++ b/src/generic/loading-button/index.jsx @@ -0,0 +1,61 @@ +// @ts-check +import React, { useState } from 'react'; + +import { + Button, + Spinner, + Stack, +} from '@edx/paragon'; + +/** + * A button that shows a loading spinner when clicked. + * @param {object} props + * @param {React.ReactNode=} props.children + * @param {boolean=} props.disabled + * @param {function=} props.onClick + * @returns {JSX.Element} + */ +const LoadingButton = ({ + onClick, + children, + disabled, + ...props +}) => { + const [isLoading, setIsLoading] = useState(false); + + const loadingOnClick = async (e) => { + if (!onClick) { + return; + } + + setIsLoading(true); + try { + await onClick(e); + } finally { + setIsLoading(false); + } + }; + + return ( + + ); +}; + +LoadingButton.propTypes = { + ...Button.propTypes, +}; + +LoadingButton.defaultProps = { + ...Button.defaultProps, +}; + +export default LoadingButton; 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/TaxonomyLayout.jsx b/src/taxonomy/TaxonomyLayout.jsx index 093ee743d3..c751370369 100644 --- a/src/taxonomy/TaxonomyLayout.jsx +++ b/src/taxonomy/TaxonomyLayout.jsx @@ -1,32 +1,51 @@ +// @ts-check import React, { useMemo, useState } from 'react'; import { StudioFooter } from '@edx/frontend-component-footer'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Outlet, ScrollRestoration } from 'react-router-dom'; import { Toast } from '@edx/paragon'; +import AlertMessage from '../generic/alert-message'; import Header from '../header'; import { TaxonomyContext } from './common/context'; +import messages from './messages'; const TaxonomyLayout = () => { + const intl = useIntl(); // Use `setToastMessage` to show the toast. const [toastMessage, setToastMessage] = useState(null); + // Use `setToastMessage` to show the alert. + const [alertProps, setAlertProps] = useState(null); const context = useMemo(() => ({ - toastMessage, setToastMessage, + toastMessage, setToastMessage, alertProps, setAlertProps, }), []); return (
+ { alertProps && ( + setAlertProps(null)} + // @ts-ignore ToDo: fix object spread type error + {...alertProps} + /> + )} - setToastMessage(null)} - data-testid="taxonomy-toast" - > - {toastMessage} - + {toastMessage && ( + setToastMessage(null)} + data-testid="taxonomy-toast" + > + {toastMessage} + + )}
diff --git a/src/taxonomy/TaxonomyLayout.test.jsx b/src/taxonomy/TaxonomyLayout.test.jsx index aeece70925..3297c99989 100644 --- a/src/taxonomy/TaxonomyLayout.test.jsx +++ b/src/taxonomy/TaxonomyLayout.test.jsx @@ -1,32 +1,50 @@ -import React from 'react'; +import React, { useContext } from 'react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { initializeMockApp } from '@edx/frontend-platform'; import { AppProvider } from '@edx/frontend-platform/react'; -import { render, act } from '@testing-library/react'; +import { render } from '@testing-library/react'; import initializeStore from '../store'; +import { TaxonomyContext } from './common/context'; import TaxonomyLayout from './TaxonomyLayout'; let store; const toastMessage = 'Hello, this is a toast!'; +const alertErrorTitle = 'Error title'; +const alertErrorDescription = 'Error description'; + +const MockChildComponent = () => { + const { setToastMessage, setAlertProps } = useContext(TaxonomyContext); + + return ( +
+ + +
+ ); +}; + jest.mock('../header', () => jest.fn(() =>
)); jest.mock('@edx/frontend-component-footer', () => ({ StudioFooter: jest.fn(() =>
), })); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), - Outlet: jest.fn(() =>
), + Outlet: () => , ScrollRestoration: jest.fn(() =>
), })); -jest.mock('react', () => ({ - ...jest.requireActual('react'), - useState: jest.fn((initial) => { - if (initial === null) { - return [toastMessage, jest.fn()]; - } - return [initial, jest.fn()]; - }), -})); const RootWrapper = () => ( @@ -49,18 +67,31 @@ describe('', async () => { store = initializeStore(); }); - it('should render page correctly', async () => { + it('should render page correctly', () => { const { getByTestId } = render(); expect(getByTestId('mock-header')).toBeInTheDocument(); expect(getByTestId('mock-content')).toBeInTheDocument(); expect(getByTestId('mock-footer')).toBeInTheDocument(); }); - it('should show toast', async () => { + it('should show toast', () => { const { getByTestId, getByText } = render(); - act(() => { - expect(getByTestId('taxonomy-toast')).toBeInTheDocument(); - expect(getByText(toastMessage)).toBeInTheDocument(); - }); + const button = getByTestId('taxonomy-show-toast'); + button.click(); + expect(getByTestId('taxonomy-toast')).toBeInTheDocument(); + expect(getByText(toastMessage)).toBeInTheDocument(); + }); + + it('should show alert', () => { + const { getByTestId, getByText, getByRole } = render(); + const button = getByTestId('taxonomy-show-alert'); + button.click(); + expect(getByTestId('taxonomy-alert')).toBeInTheDocument(); + expect(getByText(alertErrorTitle)).toBeInTheDocument(); + expect(getByText(alertErrorDescription)).toBeInTheDocument(); + + const closeAlertButton = getByRole('button', { name: 'Dismiss' }); + closeAlertButton.click(); + expect(() => getByTestId('taxonomy-alert')).toThrow(); }); }); diff --git a/src/taxonomy/TaxonomyListPage.jsx b/src/taxonomy/TaxonomyListPage.jsx index ef10996dd8..4e77b12641 100644 --- a/src/taxonomy/TaxonomyListPage.jsx +++ b/src/taxonomy/TaxonomyListPage.jsx @@ -1,4 +1,5 @@ -import React from 'react'; +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; import { Button, CardView, @@ -8,13 +9,18 @@ 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'; @@ -23,6 +29,9 @@ import { importTaxonomy } from './import-tags'; import messages from './messages'; import TaxonomyCard from './taxonomy-card'; +const ALL_TAXONOMIES = 'All taxonomies'; +const UNASSIGNED = 'Unassigned'; + const TaxonomyListHeaderButtons = () => { const intl = useIntl(); return ( @@ -69,20 +78,94 @@ 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 [selectedOrgFilter, setSelectedOrgFilter] = useState(ALL_TAXONOMIES); + + 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 ( @@ -150,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 f10bc70d19..96cfe8d737 100644 --- a/src/taxonomy/TaxonomyListPage.test.jsx +++ b/src/taxonomy/TaxonomyListPage.test.jsx @@ -1,9 +1,11 @@ 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 { 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'; @@ -13,12 +15,15 @@ import { importTaxonomy } from './import-tags'; import { TaxonomyContext } from './common/context'; let store; +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'), @@ -34,7 +39,6 @@ const context = { toastMessage: null, setToastMessage: jest.fn(), }; - const queryClient = new QueryClient(); const RootWrapper = () => ( @@ -49,7 +53,7 @@ const RootWrapper = () => ( ); -describe('', async () => { +describe('', () => { beforeEach(async () => { initializeMockApp({ authenticatedUser: { @@ -60,6 +64,8 @@ describe('', async () => { }, }); store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock.onGet(organizationsListUrl).reply(200, organizations); }); it('should render page and page title correctly', () => { @@ -114,12 +120,86 @@ describe('', async () => { description: 'This is a description', }], }); - await act(async () => { - const { getByTestId } = render(); - const importButton = getByTestId('taxonomy-import-button'); - expect(importButton).toBeInTheDocument(); - importButton.click(); - expect(importTaxonomy).toHaveBeenCalled(); + + const { getByRole } = render(); + const importButton = getByRole('button', { name: 'Import' }); + expect(importButton).toBeInTheDocument(); + fireEvent.click(importButton); + expect(importTaxonomy).toHaveBeenCalled(); + }); + + it('should show all "All taxonomies", "Unassigned" and org names in taxonomy org filter', async () => { + useIsTaxonomyListDataLoaded.mockReturnValue(true); + useTaxonomyListDataResponse.mockReturnValue({ + results: [{ + id: 1, + name: 'Taxonomy', + description: 'This is a description', + }], }); + + const { + getByTestId, + getByText, + getByRole, + getAllByText, + } = render(); + + expect(getByTestId('taxonomy-orgs-filter-selector')).toBeInTheDocument(); + // Check that the default filter is set to 'All taxonomies' when page is loaded + expect(getByText('All taxonomies')).toBeInTheDocument(); + + // Open the taxonomies org filter select menu + fireEvent.click(getByRole('button', { name: 'All taxonomies' })); + + // Check that the select menu shows 'All taxonomies' option + // along with the default selected one + expect(getAllByText('All taxonomies').length).toBe(2); + // Check that the select manu shows 'Unassigned' option + expect(getByText('Unassigned')).toBeInTheDocument(); + // Check that the select menu shows the 'Org 1' option + expect(getByText('Org 1')).toBeInTheDocument(); + // Check that the select menu shows the 'Org 2' option + expect(getByText('Org 2')).toBeInTheDocument(); + }); + + it('should fetch taxonomies with correct params for org filters', async () => { + useIsTaxonomyListDataLoaded.mockReturnValue(true); + useTaxonomyListDataResponse.mockReturnValue({ + results: taxonomies, + }); + + const { getByRole } = render(); + + // Open the taxonomies org filter select menu + const taxonomiesFilterSelectMenu = await getByRole('button', { name: 'All taxonomies' }); + fireEvent.click(taxonomiesFilterSelectMenu); + + // Check that the 'Unassigned' option is correctly called + fireEvent.click(getByRole('link', { name: 'Unassigned' })); + + expect(useTaxonomyListDataResponse).toBeCalledWith('Unassigned'); + + // Open the taxonomies org filter select menu again + fireEvent.click(taxonomiesFilterSelectMenu); + + // Check that the 'Org 1' option is correctly called + fireEvent.click(getByRole('link', { name: 'Org 1' })); + expect(useTaxonomyListDataResponse).toBeCalledWith('Org 1'); + + // Open the taxonomies org filter select menu again + fireEvent.click(taxonomiesFilterSelectMenu); + + // Check that the 'Org 2' option is correctly called + fireEvent.click(getByRole('link', { name: 'Org 2' })); + expect(useTaxonomyListDataResponse).toBeCalledWith('Org 2'); + + // Open the taxonomies org filter select menu again + fireEvent.click(taxonomiesFilterSelectMenu); + + // Check that the 'All' option is correctly called, it should show as + // 'All' rather than 'All taxonomies' in the select menu since its not selected + fireEvent.click(getByRole('link', { name: 'All' })); + expect(useTaxonomyListDataResponse).toBeCalledWith('All taxonomies'); }); }); diff --git a/src/taxonomy/common/context.js b/src/taxonomy/common/context.js index a930182f77..83e608750c 100644 --- a/src/taxonomy/common/context.js +++ b/src/taxonomy/common/context.js @@ -2,7 +2,15 @@ /* eslint-disable import/prefer-default-export */ import React from 'react'; +/** + * @typedef AlertProps + * @type {Object} + * @property {React.ReactNode} title - title of the alert. + * @property {React.ReactNode} description - description of the alert. + */ export const TaxonomyContext = React.createContext({ toastMessage: /** @type{null|string} */ (null), - setToastMessage: /** @type{null|function} */ (null), + setToastMessage: /** @type{null|React.Dispatch>} */ (null), + alertProps: /** @type{null|AlertProps} */ (null), + setAlertProps: /** @type{null|React.Dispatch>} */ (null), }); diff --git a/src/taxonomy/data/api.js b/src/taxonomy/data/api.js index 95a675ba4a..be3c276e16 100644 --- a/src/taxonomy/data/api.js +++ b/src/taxonomy/data/api.js @@ -9,7 +9,11 @@ export const getTaxonomyListApiUrl = (org) => { url.searchParams.append('enabled', 'true'); url.searchParams.append('page_size', '500'); // For the tagging MVP, we don't paginate the taxonomy list if (org !== undefined) { - url.searchParams.append('org', org); + if (org === 'Unassigned') { + url.searchParams.append('unassigned', 'true'); + } else if (org !== 'All taxonomies') { + url.searchParams.append('org', org); + } } return url.href; }; diff --git a/src/taxonomy/data/api.test.js b/src/taxonomy/data/api.test.js index b95ed9a57b..dc277db8d7 100644 --- a/src/taxonomy/data/api.test.js +++ b/src/taxonomy/data/api.test.js @@ -45,8 +45,12 @@ describe('taxonomy api calls', () => { window.location = location; }); - it('should get taxonomy list data with org', async () => { - const org = 'testOrg'; + it.each([ + undefined, + 'All taxonomies', + 'Unassigned', + 'testOrg', + ])('should get taxonomy list data for \'%s\' org filter', async (org) => { axiosMock.onGet(getTaxonomyListApiUrl(org)).reply(200, taxonomyListMock); const result = await getTaxonomyListData(org); diff --git a/src/taxonomy/data/apiHooks.jsx b/src/taxonomy/data/apiHooks.jsx index 827057ea38..eea6d5d9f4 100644 --- a/src/taxonomy/data/apiHooks.jsx +++ b/src/taxonomy/data/apiHooks.jsx @@ -20,7 +20,7 @@ import { getTaxonomyListData, deleteTaxonomy } from './api'; */ const useTaxonomyListData = (org) => ( useQuery({ - queryKey: ['taxonomyList'], + queryKey: ['taxonomyList', org], queryFn: () => getTaxonomyListData(org), }) ); diff --git a/src/taxonomy/import-tags/ImportTagsWizard.jsx b/src/taxonomy/import-tags/ImportTagsWizard.jsx new file mode 100644 index 0000000000..40edf1c718 --- /dev/null +++ b/src/taxonomy/import-tags/ImportTagsWizard.jsx @@ -0,0 +1,398 @@ +// @ts-check +import React, { useState, useContext } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + useToggle, + Button, + Container, + Dropzone, + Icon, + IconButton, + ModalDialog, + Stack, + Stepper, +} from '@edx/paragon'; +import { + DeleteOutline, + Download, + Error as ErrorIcon, + InsertDriveFile, + Warning, +} from '@edx/paragon/icons'; +import PropTypes from 'prop-types'; + +import LoadingButton from '../../generic/loading-button'; +import { getFileSizeToClosestByte } from '../../utils'; +import { TaxonomyContext } from '../common/context'; +import { getTaxonomyExportFile } from '../data/api'; +import { planImportTags, useImportTags } from './data/api'; +import messages from './messages'; + +const linebreak = <>

; + +const TaxonomyProp = PropTypes.shape({ + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, +}); + +const ExportStep = ({ taxonomy, title }) => { + const intl = useIntl(); + + return ( + + +

{intl.formatMessage(messages.importWizardStepExportBody, { br: linebreak })}

+ + + + +
+
+ ); +}; + +ExportStep.propTypes = { + taxonomy: TaxonomyProp.isRequired, + title: PropTypes.string.isRequired, +}; + +const UploadStep = ({ + file, + setFile, + importPlanError, + setImportPlanError, + title, +}) => { + const intl = useIntl(); + + /** @type {(args: {fileData: FormData}) => void} */ + const handleFileLoad = ({ fileData }) => { + setFile(fileData.get('file')); + setImportPlanError(null); + }; + + const clearFile = (e) => { + e.stopPropagation(); + setFile(null); + setImportPlanError(null); + }; + + return ( + + +

{intl.formatMessage(messages.importWizardStepUploadBody, { br: linebreak })}

+
+ {!file ? ( + + ) : ( + + + +
{file.name}
+
{getFileSizeToClosestByte(file.size)}
+
+ +
+ )} +
+ + {importPlanError && {importPlanError}} +
+
+ ); +}; + +UploadStep.propTypes = { + file: PropTypes.shape({ + name: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + }), + setFile: PropTypes.func.isRequired, + importPlanError: PropTypes.string, + setImportPlanError: PropTypes.func.isRequired, + title: PropTypes.string.isRequired, +}; + +UploadStep.defaultProps = { + file: null, + importPlanError: null, +}; + +const PlanStep = ({ importPlan, title }) => { + const intl = useIntl(); + + return ( + + + {intl.formatMessage(messages.importWizardStepPlanBody, { br: linebreak, changeCount: importPlan?.length })} +
    + {importPlan?.length ? ( + importPlan.map((line) =>
  • {line}
  • ) + ) : ( +
  • {intl.formatMessage(messages.importWizardStepPlanNoChanges)}
  • + )} +
+
+
+ ); +}; + +PlanStep.propTypes = { + importPlan: PropTypes.arrayOf(PropTypes.string), + title: PropTypes.string.isRequired, +}; + +PlanStep.defaultProps = { + importPlan: null, +}; + +const ConfirmStep = ({ importPlan, title }) => { + const intl = useIntl(); + + return ( + + + {intl.formatMessage( + messages.importWizardStepConfirmBody, + { br: linebreak, changeCount: importPlan?.length }, + )} + + + ); +}; + +ConfirmStep.propTypes = { + importPlan: PropTypes.arrayOf(PropTypes.string), + title: PropTypes.string.isRequired, +}; + +ConfirmStep.defaultProps = { + importPlan: null, +}; + +const ImportTagsWizard = ({ + taxonomy, + isOpen, + onClose, +}) => { + const intl = useIntl(); + const { setToastMessage, setAlertProps } = useContext(TaxonomyContext); + + const steps = ['export', 'upload', 'plan', 'confirm']; + const [currentStep, setCurrentStep] = useState(steps[0]); + + const [file, setFile] = useState(/** @type {null|File} */ (null)); + + const [importPlan, setImportPlan] = useState(/** @type {null|string[]} */ (null)); + const [importPlanError, setImportPlanError] = useState(null); + + const [isDialogDisabled, disableDialog, enableDialog] = useToggle(false); + + const importTagsMutation = useImportTags(); + + const generatePlan = async () => { + disableDialog(); + try { + if (file) { + const plan = await planImportTags(taxonomy.id, file); + let planArrayTemp = plan.split('\n'); + planArrayTemp = planArrayTemp.slice(2); // Removes the first two lines + planArrayTemp = planArrayTemp.slice(0, -1); // Removes the last line + const planArray = planArrayTemp + .filter((line) => !(line.includes('No changes'))) // Removes the "No changes" lines + .map((line) => line.split(':')[1].trim()); // Get only the action message + setImportPlan(planArray); + setImportPlanError(null); + setCurrentStep('plan'); + } + } catch (/** @type {any} */ error) { + setImportPlanError(error.message); + } finally { + enableDialog(); + } + }; + + const confirmImportTags = async () => { + disableDialog(); + try { + if (file) { + await importTagsMutation.mutateAsync({ + taxonomyId: taxonomy.id, + file, + }); + } + if (setToastMessage) { + setToastMessage(intl.formatMessage(messages.importTaxonomyToast, { name: taxonomy.name })); + } + } catch (/** @type {any} */ error) { + const alertProps = { + variant: 'danger', + icon: ErrorIcon, + title: intl.formatMessage(messages.importTaxonomyErrorAlert), + description: error.message, + }; + + if (setAlertProps) { + setAlertProps(alertProps); + } + } finally { + enableDialog(); + onClose(); + } + }; + + const stepTitles = { + export: intl.formatMessage(messages.importWizardStepExportTitle, { name: taxonomy.name }), + upload: intl.formatMessage(messages.importWizardStepUploadTitle), + plan: intl.formatMessage(messages.importWizardStepPlanTitle), + confirm: ( + + + {intl.formatMessage(messages.importWizardStepConfirmTitle, { changeCount: importPlan?.length })} + + ), + }; + + return ( + e.stopPropagation() /* This prevents calling onClick handler from the parent */} + > + + {isDialogDisabled && ( + // This div is used to prevent the user from interacting with the dialog while it is disabled +
+ )} + + + {stepTitles[currentStep]} + + + +
+ + + + + + + + + +
+ + + + + + + + + + + + + + {intl.formatMessage(messages.importWizardButtonImport)} + + + + + + + + + + + + + + + + {intl.formatMessage(messages.importWizardButtonConfirm)} + + + + +
+ + + ); +}; + +ImportTagsWizard.propTypes = { + taxonomy: TaxonomyProp.isRequired, + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, +}; + +export default ImportTagsWizard; diff --git a/src/taxonomy/import-tags/ImportTagsWizard.test.jsx b/src/taxonomy/import-tags/ImportTagsWizard.test.jsx new file mode 100644 index 0000000000..0aa669045e --- /dev/null +++ b/src/taxonomy/import-tags/ImportTagsWizard.test.jsx @@ -0,0 +1,242 @@ +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 { getTaxonomyExportFile } from '../data/api'; +import { TaxonomyContext } from '../common/context'; +import { planImportTags } from './data/api'; +import ImportTagsWizard from './ImportTagsWizard'; + +let store; + +const queryClient = new QueryClient(); + +jest.mock('../data/api', () => ({ + ...jest.requireActual('../data/api'), + getTaxonomyExportFile: jest.fn(), +})); + +const mockUseImportTagsMutate = jest.fn(); + +jest.mock('./data/api', () => ({ + ...jest.requireActual('./data/api'), + planImportTags: jest.fn(), + useImportTags: jest.fn(() => ({ + ...jest.requireActual('./data/api').useImportTags(), + mutateAsync: mockUseImportTagsMutate, + })), +})); + +const mockSetToastMessage = jest.fn(); +const mockSetAlertProps = jest.fn(); +const context = { + toastMessage: null, + setToastMessage: mockSetToastMessage, + alertProps: null, + setAlertProps: mockSetAlertProps, +}; + +const taxonomy = { + id: 1, + name: 'Test Taxonomy', +}; + +const RootWrapper = ({ onClose }) => ( + + + + + + + + + +); + +RootWrapper.propTypes = { + onClose: PropTypes.func.isRequired, +}; + +describe('', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + }); + + it('render the dialog in the first step can close on cancel', async () => { + const onClose = jest.fn(); + const { getByTestId } = render(); + + await waitFor(() => { + expect(getByTestId('export-step')).toBeInTheDocument(); + }); + + const cancelButton = getByTestId('cancel-button'); + cancelButton.click(); + expect(onClose).toHaveBeenCalled(); + }); + + it('can export taxonomies from the dialog', async () => { + const onClose = jest.fn(); + const { getByTestId } = render(); + + await waitFor(() => { + expect(getByTestId('export-step')).toBeInTheDocument(); + }); + + const exportJsonButton = getByTestId('export-json-button'); + exportJsonButton.click(); + expect(getTaxonomyExportFile).toHaveBeenCalledWith(taxonomy.id, 'json'); + const exportCsvButton = getByTestId('export-csv-button'); + exportCsvButton.click(); + expect(getTaxonomyExportFile).toHaveBeenCalledWith(taxonomy.id, 'csv'); + }); + + it.each(['success', 'error'])('an upload taxonomies from the dialog (%p)', async (expectedResult) => { + const onClose = jest.fn(); + const { getAllByTestId, getByTestId, getByText } = render(); + + await waitFor(() => { + expect(getByTestId('export-step')).toBeInTheDocument(); + }); + + fireEvent.click(getByTestId('next-button')); + + expect(getByTestId('upload-step')).toBeInTheDocument(); + + // Test back button + fireEvent.click(getByTestId('back-button')); + expect(getByTestId('export-step')).toBeInTheDocument(); + fireEvent.click(getByTestId('next-button')); + expect(getByTestId('upload-step')).toBeInTheDocument(); + + const importButton = getByTestId('import-button'); + expect(importButton).toBeDisabled(); + + // Invalid file type + const fileTarGz = new File(['file contents'], 'example.tar.gz', { type: 'application/gzip' }); + fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [fileTarGz], types: ['Files'] } }); + expect(importButton).toBeDisabled(); + expect(getByTestId('dropzone')).toBeInTheDocument(); + + // Correct file type + const fileJson = new File(['file contents'], 'example.json', { type: 'application/gzip' }); + fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [fileJson], types: ['Files'] } }); + await waitFor(() => { + expect(getByTestId('file-info')).toBeInTheDocument(); + }); + expect(getByText('example.json')).toBeInTheDocument(); + + // Clear file + const clearFileButton = getByTestId('clear-file-button'); + clearFileButton.click(); + await waitFor(() => { + expect(getByTestId('dropzone')).toBeInTheDocument(); + }); + + // Reselect file + fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [fileJson], types: ['Files'] } }); + await waitFor(() => { + expect(getByTestId('file-info')).toBeInTheDocument(); + }); + + planImportTags.mockRejectedValueOnce(new Error('Test error')); + importButton.click(); + + expect(planImportTags).toHaveBeenCalledWith(taxonomy.id, fileJson); + await waitFor(() => { + expect(getByText('Test error')).toBeInTheDocument(); + }); + const errorAlert = getByText('Test error'); + + // Reselect file to clear the error + fireEvent.click(getByTestId('clear-file-button')); + expect(errorAlert).not.toBeInTheDocument(); + fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [fileJson], types: ['Files'] } }); + + await waitFor(() => { + expect(getByTestId('file-info')).toBeInTheDocument(); + }); + + const expectedPlan = 'Import plan for Test import taxonomy\n' + + '--------------------------------\n' + + '#1: Create a new tag with values (external_id=tag_1, value=Tag 1, parent_id=None).\n' + + '#2: Create a new tag with values (external_id=tag_2, value=Tag 2, parent_id=None).\n' + + '#3: Create a new tag with values (external_id=tag_3, value=Tag 3, parent_id=None).\n' + + '#4: Create a new tag with values (external_id=tag_4, value=Tag 4, parent_id=None).\n' + + '#5: Delete tag (external_id=old_tag_1)\n' + + '#6: Delete tag (external_id=old_tag_2)\n'; + planImportTags.mockResolvedValueOnce(expectedPlan); + + expect(importButton).not.toBeDisabled(); + + importButton.click(); + + await waitFor(() => { + expect(getByTestId('plan-step')).toBeInTheDocument(); + }); + + // Test back button + fireEvent.click(getByTestId('back-button')); + expect(getByTestId('upload-step')).toBeInTheDocument(); + planImportTags.mockResolvedValueOnce(expectedPlan); + fireEvent.click(getByTestId('import-button')); + await waitFor(() => { + expect(getByTestId('plan-step')).toBeInTheDocument(); + }); + + expect(getAllByTestId('plan-action')).toHaveLength(6); + + fireEvent.click(getByTestId('continue-button')); + + expect(getByTestId('confirm-step')).toBeInTheDocument(); + + // Test back button + fireEvent.click(getByTestId('back-button')); + expect(getByTestId('plan-step')).toBeInTheDocument(); + fireEvent.click(getByTestId('continue-button')); + expect(getByTestId('confirm-step')).toBeInTheDocument(); + + if (expectedResult === 'success') { + mockUseImportTagsMutate.mockResolvedValueOnce({}); + } else { + mockUseImportTagsMutate.mockRejectedValueOnce(new Error('Test error')); + } + + fireEvent.click(getByTestId('confirm-button')); + + await waitFor(() => { + expect(mockUseImportTagsMutate).toHaveBeenCalledWith({ taxonomyId: taxonomy.id, file: fileJson }); + }); + + if (expectedResult === 'success') { + // Toast message shown + expect(mockSetToastMessage).toBeCalledWith(`"${taxonomy.name}" updated`); + } else { + // Alert message shown + expect(mockSetAlertProps).toBeCalledWith( + expect.objectContaining({ + variant: 'danger', + title: 'Import error', + description: 'Test error', + }), + ); + } + }); +}); diff --git a/src/taxonomy/import-tags/__mocks__/index.js b/src/taxonomy/import-tags/__mocks__/index.js index ba0b48ccb9..78ef2f5e8f 100644 --- a/src/taxonomy/import-tags/__mocks__/index.js +++ b/src/taxonomy/import-tags/__mocks__/index.js @@ -1,2 +1 @@ -export { default as taxonomyImportMock } from './taxonomyImportMock'; -export { default as tagImportMock } from './tagImportMock'; +export { default as taxonomyImportMock } from './taxonomyImportMock'; // eslint-disable-line import/prefer-default-export diff --git a/src/taxonomy/import-tags/data/api.js b/src/taxonomy/import-tags/data/api.js index befb2e977d..a31abace60 100644 --- a/src/taxonomy/import-tags/data/api.js +++ b/src/taxonomy/import-tags/data/api.js @@ -1,6 +1,7 @@ // @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; @@ -18,14 +19,24 @@ export const getTagsImportApiUrl = (taxonomyId) => new URL( getApiBaseUrl(), ).href; +/** + * @param {number} taxonomyId + * @returns {string} + */ +export const getTagsPlanImportApiUrl = (taxonomyId) => new URL( + `api/content_tagging/v1/taxonomies/${taxonomyId}/tags/import/plan/`, + getApiBaseUrl(), +).href; + /** * Import a new taxonomy * @param {string} taxonomyName * @param {string} taxonomyDescription * @param {File} file - * @returns {Promise} + * @returns {Promise} */ export async function importNewTaxonomy(taxonomyName, taxonomyDescription, file) { + // ToDo: transform this to use react-query like useImportTags const formData = new FormData(); formData.append('taxonomy_name', taxonomyName); formData.append('taxonomy_description', taxonomyDescription); @@ -40,19 +51,63 @@ export async function importNewTaxonomy(taxonomyName, taxonomyDescription, file) } /** - * Import tags to an existing taxonomy, overwriting existing tags + * Build the mutation to import tags to an existing taxonomy + */ +export const useImportTags = () => { + const queryClient = useQueryClient(); + return useMutation({ + /** + * @type {import("@tanstack/react-query").MutateFunction< + * any, + * any, + * { + * taxonomyId: number + * file: File + * } + * >} + */ + mutationFn: async ({ taxonomyId, file }) => { + const formData = new FormData(); + formData.append('file', file); + + try { + const { data } = await getAuthenticatedHttpClient().put( + getTagsImportApiUrl(taxonomyId), + formData, + ); + + return camelCaseObject(data); + } catch (/** @type {any} */ err) { + throw new Error(err.response?.data || err.message); + } + }, + onSuccess: (data, variables) => { + queryClient.invalidateQueries({ + queryKey: ['tagList', variables.taxonomyId], + }); + queryClient.setQueryData(['taxonomyDetail', variables.taxonomyId], data); + }, + }); +}; + +/** + * Plan import tags to an existing taxonomy, overwriting existing tags * @param {number} taxonomyId * @param {File} file - * @returns {Promise} + * @returns {Promise} */ -export async function importTags(taxonomyId, file) { +export async function planImportTags(taxonomyId, file) { const formData = new FormData(); formData.append('file', file); - const { data } = await getAuthenticatedHttpClient().put( - getTagsImportApiUrl(taxonomyId), - formData, - ); + try { + const { data } = await getAuthenticatedHttpClient().put( + getTagsPlanImportApiUrl(taxonomyId), + formData, + ); - return camelCaseObject(data); + return data.plan; + } catch (/** @type {any} */ err) { + throw new Error(err.response?.data?.error || err.message); + } } diff --git a/src/taxonomy/import-tags/data/api.test.js b/src/taxonomy/import-tags/data/api.test.js deleted file mode 100644 index 0da9f84eae..0000000000 --- a/src/taxonomy/import-tags/data/api.test.js +++ /dev/null @@ -1,48 +0,0 @@ -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/api.test.jsx b/src/taxonomy/import-tags/data/api.test.jsx new file mode 100644 index 0000000000..6461e6808b --- /dev/null +++ b/src/taxonomy/import-tags/data/api.test.jsx @@ -0,0 +1,88 @@ +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 { taxonomyImportMock } from '../__mocks__'; + +import { + getTaxonomyImportNewApiUrl, + getTagsImportApiUrl, + getTagsPlanImportApiUrl, + importNewTaxonomy, + planImportTags, + useImportTags, +} 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 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 () => { + const taxonomy = { id: 1, name: 'taxonomy name' }; + axiosMock.onPut(getTagsImportApiUrl(1)).reply(200, taxonomy); + const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); + const mockSetQueryData = jest.spyOn(queryClient, 'setQueryData'); + + const { result } = renderHook(() => useImportTags(), { wrapper }); + + await result.current.mutateAsync({ taxonomyId: 1 }); + expect(axiosMock.history.put[0].url).toEqual(getTagsImportApiUrl(1)); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['tagList', 1], + }); + expect(mockSetQueryData).toHaveBeenCalledWith(['taxonomyDetail', 1], taxonomy); + }); + + it('should call plan import tags', async () => { + axiosMock.onPut(getTagsPlanImportApiUrl(1)).reply(200, { plan: 'plan' }); + await planImportTags(1); + expect(axiosMock.history.put[0].url).toEqual(getTagsPlanImportApiUrl(1)); + }); + + it('should handle errors in plan import tags', async () => { + axiosMock.onPut(getTagsPlanImportApiUrl(1)).reply(400, { error: 'test error' }); + + expect(planImportTags(1)).rejects.toEqual(Error('test error')); + expect(axiosMock.history.put[0].url).toEqual(getTagsPlanImportApiUrl(1)); + }); +}); diff --git a/src/taxonomy/import-tags/data/utils.js b/src/taxonomy/import-tags/data/utils.js index e46e76941e..71377ac72d 100644 --- a/src/taxonomy/import-tags/data/utils.js +++ b/src/taxonomy/import-tags/data/utils.js @@ -1,6 +1,6 @@ -// ts-check +// @ts-check import messages from '../messages'; -import { importNewTaxonomy, importTags } from './api'; +import { importNewTaxonomy } from './api'; /* * This function get a file from the user. It does this by creating a @@ -10,13 +10,14 @@ import { importNewTaxonomy, importTags } from './api'; * 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', (event) => { - const file = event.target.files[0]; + fileInput.addEventListener('change', (/** @type { Event & { target: HTMLInputElement} } */ event) => { + const file = event.target.files?.[0]; if (!file) { resolve(null); } @@ -36,7 +37,8 @@ const selectFile = async () => new Promise((resolve) => { setTimeout(() => fileInput.click(), 0); }); -export const importTaxonomy = async (intl) => { +/* istanbul ignore next */ +export const importTaxonomy = async (intl) => { // eslint-disable-line import/prefer-default-export /* * This function is a temporary "Barebones" implementation of the import * functionality with `prompt` and `alert`. It is intended to be replaced @@ -89,32 +91,3 @@ export const importTaxonomy = async (intl) => { console.error(error.response); }); }; - -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 deleted file mode 100644 index ddcc029410..0000000000 --- a/src/taxonomy/import-tags/data/utils.test.js +++ /dev/null @@ -1,301 +0,0 @@ -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 index 78be9bed95..32815d7b0f 100644 --- a/src/taxonomy/import-tags/index.js +++ b/src/taxonomy/import-tags/index.js @@ -1,2 +1,3 @@ // @ts-check -export { importTaxonomyTags, importTaxonomy } from './data/utils'; +export { importTaxonomy } from './data/utils'; +export { default as ImportTagsWizard } from './ImportTagsWizard'; diff --git a/src/taxonomy/import-tags/messages.js b/src/taxonomy/import-tags/messages.js index eaa6780d9f..70a2006a1c 100644 --- a/src/taxonomy/import-tags/messages.js +++ b/src/taxonomy/import-tags/messages.js @@ -1,7 +1,85 @@ -// ts-check +// @ts-check import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ + importWizardButtonCancel: { + id: 'course-authoring.import-tags.wizard.button.cancel', + defaultMessage: 'Cancel', + }, + importWizardButtonNext: { + id: 'course-authoring.import-tags.wizard.button.next', + defaultMessage: 'Next', + }, + importWizardButtonPrevious: { + id: 'course-authoring.import-tags.wizard.button.previous', + defaultMessage: 'Previous', + }, + importWizardButtonImport: { + id: 'course-authoring.import-tags.wizard.button.import', + defaultMessage: 'Import', + }, + importWizardButtonContinue: { + id: 'course-authoring.import-tags.wizard.button.continue', + defaultMessage: 'Continue', + }, + importWizardButtonConfirm: { + id: 'course-authoring.import-tags.wizard.button.confirm', + defaultMessage: 'Yes, import file', + }, + importWizardStepExportTitle: { + id: 'course-authoring.import-tags.wizard.step-export.title', + defaultMessage: 'Update "{name}"', + }, + importWizardStepExportBody: { + id: 'course-authoring.import-tags.wizard.step-export.body', + defaultMessage: 'To update this taxonomy you need to import a new CSV or JSON file. The current taxonomy will ' + + 'be completely replaced by the contents of the imported file (e.g. if a tag in the current taxonomy is not ' + + 'present in the imported file, it will be removed - both from the taxonomy and from any tagged course ' + + 'content).' + + '{br}You may wish to download the taxonomy in its current state before importing the new file.', + }, + importWizardStepExportCSVButton: { + id: 'course-authoring.import-tags.wizard.step-export.button-csv', + defaultMessage: 'CSV file', + }, + importWizardStepExportJSONButton: { + id: 'course-authoring.import-tags.wizard.step-export.button-json', + defaultMessage: 'JSON file', + }, + importWizardStepUploadTitle: { + id: 'course-authoring.import-tags.wizard.step-upload.title', + defaultMessage: 'Upload file', + }, + importWizardStepUploadBody: { + id: 'course-authoring.import-tags.wizard.step-upload.body', + defaultMessage: 'You may use any spreadsheet tool (for CSV files), or any text editor (for JSON files) to create ' + + 'the file that you wish to import.' + + '{br}Once the file is ready to be imported, drag and drop it into the box below, or click to upload.', + }, + importWizardStepPlanTitle: { + id: 'course-authoring.import-tags.wizard.step-plan.title', + defaultMessage: 'Differences between files', + }, + importWizardStepPlanBody: { + id: 'course-authoring.import-tags.wizard.step-plan.body', + defaultMessage: 'Importing this file will make {changeCount} updates to the existing taxonomy. ' + + 'The content of the imported file will replace any existing values that do not match the new values.' + + '{br}Importing this file will cause the following updates:', + }, + importWizardStepPlanNoChanges: { + id: 'course-authoring.import-tags.wizard.step-plan.no-changes', + defaultMessage: 'No changes', + }, + importWizardStepConfirmTitle: { + id: 'course-authoring.import-tags.wizard.step-confirm.title', + defaultMessage: 'Import and replace tags', + }, + importWizardStepConfirmBody: { + id: 'course-authoring.import-tags.wizard.step-confirm.body', + defaultMessage: 'Warning! You are about to make {changeCount} changes to the existing taxonomy. Any tags applied ' + + 'to course content will be updated or removed. This cannot be undone.' + + '{br}Are you sure you want to continue importing this file?', + }, promptTaxonomyName: { id: 'course-authoring.import-tags.prompt.taxonomy-name', defaultMessage: 'Enter a name for the new taxonomy', @@ -22,11 +100,13 @@ const messages = defineMessages({ 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?', + importTaxonomyToast: { + id: 'course-authoring.import-tags.toast.success', + defaultMessage: '"{name}" updated', + }, + importTaxonomyErrorAlert: { + id: 'course-authoring.import-tags.error-alert.title', + defaultMessage: 'Import error', }, }); 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/messages.js b/src/taxonomy/messages.js index ace08d33dc..045b0841f8 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', @@ -37,6 +45,10 @@ const messages = defineMessages({ id: 'course-authoring.taxonomy-list.toast.delete', defaultMessage: '"{name}" deleted', }, + taxonomyDismissLabel: { + id: 'course-authoring.taxonomy-list.alert.dismiss', + defaultMessage: 'Dismiss', + }, }); export default messages; diff --git a/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx b/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx index 1b682828b2..6cbbf4c630 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx +++ b/src/taxonomy/taxonomy-card/TaxonomyCard.test.jsx @@ -63,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', () => { @@ -78,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', () => { diff --git a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx index 53bf06426f..1f245356b6 100644 --- a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx +++ b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx @@ -110,4 +110,20 @@ describe('', async () => { const { getByText } = render(); expect(getByText('System-level')).toBeInTheDocument(); }); + + it('should not show system defined badge', async () => { + useTaxonomyDetailData.mockReturnValue({ + isSuccess: true, + isFetched: true, + isError: false, + data: { + id: 1, + name: 'Test taxonomy', + description: 'This is a description', + systemDefined: false, + }, + }); + const { queryByText } = render(); + expect(queryByText('System-level')).not.toBeInTheDocument(); + }); }); diff --git a/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx b/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx index 91310f7309..566587b95b 100644 --- a/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx +++ b/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx @@ -9,7 +9,7 @@ import { IconButton, } from '@edx/paragon'; import { MoreVert } from '@edx/paragon/icons'; -import _ from 'lodash'; +import { omitBy } from 'lodash'; import PropTypes from 'prop-types'; import { useNavigate } from 'react-router-dom'; @@ -17,7 +17,7 @@ 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 { ImportTagsWizard } from '../import-tags'; import messages from './messages'; const TaxonomyMenu = ({ @@ -45,34 +45,38 @@ const TaxonomyMenu = ({ const [isDeleteDialogOpen, deleteDialogOpen, deleteDialogClose] = useToggle(false); const [isExportModalOpen, exportModalOpen, exportModalClose] = useToggle(false); + const [isImportModalOpen, importModalOpen, importModalClose] = useToggle(false); - const getTaxonomyMenuItems = () => { - let menuItems = { - import: { - title: intl.formatMessage(messages.importMenu), - action: () => importTaxonomyTags(taxonomy.id, intl), - // Hide import menu item if taxonomy is system defined or allows free text - hide: taxonomy.systemDefined || taxonomy.allowFreeText, - }, - export: { - title: intl.formatMessage(messages.exportMenu), - action: exportModalOpen, - }, - delete: { - title: intl.formatMessage(messages.deleteMenu), - action: deleteDialogOpen, - // Hide delete menu item if taxonomy is system defined - hide: taxonomy.systemDefined, - }, - }; - - // Remove hidden menu items - menuItems = _.omitBy(menuItems, (value) => value.hide); - - return menuItems; + /** + * @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: importModalOpen, + // 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, + }, }; - const menuItems = getTaxonomyMenuItems(); + // Remove hidden menu items + menuItems = omitBy(menuItems, (value) => value.hide); const renderModals = () => ( <> @@ -92,6 +96,13 @@ const TaxonomyMenu = ({ taxonomyId={taxonomy.id} /> )} + {isImportModalOpen && ( + + )} ); @@ -104,7 +115,7 @@ const TaxonomyMenu = ({ variant="primary" alt={intl.formatMessage(messages.actionsButtonAlt, { name: taxonomy.name })} data-testid="taxonomy-menu-button" - disabled={menuItems.length === 0} + disabled={Object.keys(menuItems).length === 0} > {intl.formatMessage(messages.actionsButtonLabel)} @@ -116,7 +127,7 @@ const TaxonomyMenu = ({ onClick={ (e) => { e.preventDefault(); - menuItems[key].action?.(); + menuItems[key].action(); } } > diff --git a/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx b/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx index f441729b3b..41ae4c78f9 100644 --- a/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx +++ b/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx @@ -9,17 +9,12 @@ import PropTypes from 'prop-types'; import { TaxonomyContext } from '../common/context'; import initializeStore from '../../store'; import { deleteTaxonomy, getTaxonomyExportFile } from '../data/api'; -import { importTaxonomyTags } from '../import-tags'; import { TaxonomyMenu } from '.'; let store; const taxonomyId = 1; const taxonomyName = 'Taxonomy 1'; -jest.mock('../import-tags', () => ({ - importTaxonomyTags: jest.fn().mockResolvedValue({}), -})); - jest.mock('../data/api', () => ({ ...jest.requireActual('../data/api'), getTaxonomyExportFile: jest.fn(), @@ -73,7 +68,7 @@ TaxonomyMenuComponent.defaultProps = { allowFreeText: false, }; -describe('', async () => { +describe.each([true, false])('', async (iconMenu) => { beforeEach(async () => { initializeMockApp({ authenticatedUser: { @@ -90,162 +85,160 @@ describe('', async () => { jest.clearAllMocks(); }); - [true, false].forEach((iconMenu) => { - test('should open and close menu on button click', () => { - const { getByTestId } = render(); + test('should open and close menu on button click', () => { + const { getByTestId, queryByTestId } = render(); - // Menu closed/doesn't exist yet - expect(() => getByTestId('taxonomy-menu')).toThrow(); + // 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')); + // Click on the menu button to open + fireEvent.click(getByTestId('taxonomy-menu-button')); - // Menu opened - expect(getByTestId('taxonomy-menu')).toBeVisible(); + // Menu opened + expect(getByTestId('taxonomy-menu')).toBeVisible(); - // Click on button again to close the menu - fireEvent.click(getByTestId('taxonomy-menu-button')); + // 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 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(); - }); + // Menu button still visible + expect(getByTestId('taxonomy-menu-button')).toBeVisible(); + }); - test('doesnt show systemDefined taxonomies disabled menus', () => { - const { getByTestId } = render(); + test('doesnt show systemDefined taxonomies disabled menus', () => { + const { getByTestId, queryByTestId } = render(); - // Menu closed/doesn't exist yet - expect(() => getByTestId('taxonomy-menu')).toThrow(); + // 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')); + // Click on the menu button to open + fireEvent.click(getByTestId('taxonomy-menu-button')); - // Menu opened - expect(getByTestId('taxonomy-menu')).toBeVisible(); + // Menu opened + expect(getByTestId('taxonomy-menu')).toBeVisible(); - // Check that the import menu is not show - expect(() => getByTestId('taxonomy-menu-import')).toThrow(); - }); - - test('doesnt show freeText taxonomies disabled menus', () => { - const { getByTestId } = render(); + // Check that the import menu is not show + expect(queryByTestId('taxonomy-menu-import')).not.toBeInTheDocument(); + }); - // Menu closed/doesn't exist yet - expect(() => getByTestId('taxonomy-menu')).toThrow(); + test('doesnt show freeText taxonomies disabled menus', () => { + const { getByTestId, queryByTestId } = render(); - // Click on the menu button to open - fireEvent.click(getByTestId('taxonomy-menu-button')); + // Menu closed/doesn't exist yet + expect(queryByTestId('taxonomy-menu')).not.toBeInTheDocument(); - // Menu opened - expect(getByTestId('taxonomy-menu')).toBeVisible(); + // Click on the menu button to open + fireEvent.click(getByTestId('taxonomy-menu-button')); - // Check that the import menu is not show - expect(() => getByTestId('taxonomy-menu-import')).toThrow(); - }); + // Menu opened + expect(getByTestId('taxonomy-menu')).toBeVisible(); - test('should open export modal on export menu click', () => { - const { getByTestId, getByText } = render(); + // Check that the import menu is not show + expect(queryByTestId('taxonomy-menu-import')).not.toBeInTheDocument(); + }); - // Modal closed - expect(() => getByText('Select format to export')).toThrow(); + test('should open export modal on export menu click', () => { + const { getByTestId, getByText, queryByText } = render(); - // Click on export menu - fireEvent.click(getByTestId('taxonomy-menu-button')); - fireEvent.click(getByTestId('taxonomy-menu-export')); + // Modal closed + expect(queryByText('Select format to export')).not.toBeInTheDocument(); - // Modal opened - expect(getByText('Select format to export')).toBeInTheDocument(); + // Click on export menu + fireEvent.click(getByTestId('taxonomy-menu-button')); + fireEvent.click(getByTestId('taxonomy-menu-export')); - // Click on cancel button - fireEvent.click(getByText('Cancel')); + // Modal opened + expect(getByText('Select format to export')).toBeInTheDocument(); - // Modal closed - expect(() => getByText('Select format to export')).toThrow(); - }); + // Click on cancel button + fireEvent.click(getByText('Cancel')); - test('should call import tags when menu click', () => { - const { getByTestId } = render(); + // Modal closed + expect(queryByText('Select format to export')).not.toBeInTheDocument(); + }); - // Click on import menu - fireEvent.click(getByTestId('taxonomy-menu-button')); - fireEvent.click(getByTestId('taxonomy-menu-import')); + test('should call import tags when menu click', () => { + const { getByTestId, getByText } = render(); - expect(importTaxonomyTags).toHaveBeenCalled(); - }); + // Click on import menu + fireEvent.click(getByTestId('taxonomy-menu-button')); + fireEvent.click(getByTestId('taxonomy-menu-import')); - test('should export a taxonomy', () => { - const { getByTestId, getByText } = render(); + expect(getByText('Update "Taxonomy 1"')).toBeInTheDocument(); + }); - // Click on export menu - fireEvent.click(getByTestId('taxonomy-menu-button')); - fireEvent.click(getByTestId('taxonomy-menu-export')); + test('should export a taxonomy', () => { + const { getByTestId, getByText, queryByText } = render(); - // Select JSON format and click on export - fireEvent.click(getByText('JSON file')); - fireEvent.click(getByTestId('export-button-1')); + // Click on export menu + fireEvent.click(getByTestId('taxonomy-menu-button')); + fireEvent.click(getByTestId('taxonomy-menu-export')); - // Modal closed - expect(() => getByText('Select format to export')).toThrow(); - expect(getTaxonomyExportFile).toHaveBeenCalledWith(taxonomyId, 'json'); - }); + // Select JSON format and click on export + fireEvent.click(getByText('JSON file')); + fireEvent.click(getByTestId('export-button-1')); - test('should open delete dialog on delete menu click', () => { - const { getByTestId, getByText } = render(); + // Modal closed + expect(queryByText('Select format to export')).not.toBeInTheDocument(); + expect(getTaxonomyExportFile).toHaveBeenCalledWith(taxonomyId, 'json'); + }); - // Modal closed - expect(() => getByText(`Delete "${taxonomyName}"`)).toThrow(); + test('should open delete dialog on delete menu click', () => { + const { getByTestId, getByText, queryByText } = render(); - // Click on delete menu - fireEvent.click(getByTestId('taxonomy-menu-button')); - fireEvent.click(getByTestId('taxonomy-menu-delete')); + // Modal closed + expect(queryByText(`Delete "${taxonomyName}"`)).not.toBeInTheDocument(); - // Modal opened - expect(getByText(`Delete "${taxonomyName}"`)).toBeInTheDocument(); + // Click on delete menu + fireEvent.click(getByTestId('taxonomy-menu-button')); + fireEvent.click(getByTestId('taxonomy-menu-delete')); - // Click on cancel button - fireEvent.click(getByText('Cancel')); + // Modal opened + expect(getByText(`Delete "${taxonomyName}"`)).toBeInTheDocument(); - // Modal closed - expect(() => getByText(`Delete "${taxonomyName}"`)).toThrow(); - }); + // Click on cancel button + fireEvent.click(getByText('Cancel')); - test('should delete a taxonomy', async () => { - const { getByTestId, getByText, getByLabelText } = 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')); + test('should delete a taxonomy', async () => { + const { getByTestId, getByLabelText, queryByText } = render(); - const deleteButton = getByTestId('delete-button'); + // Click on delete menu + fireEvent.click(getByTestId('taxonomy-menu-button')); + fireEvent.click(getByTestId('taxonomy-menu-delete')); - // The delete button must to be disabled - expect(deleteButton).toBeDisabled(); + const deleteButton = getByTestId('delete-button'); - // 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(); + // The delete button must to be disabled + expect(deleteButton).toBeDisabled(); - deleteTaxonomy.mockResolvedValueOnce({}); + // 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); + deleteTaxonomy.mockResolvedValueOnce({}); - // Modal closed - expect(() => getByText(`Delete "${taxonomyName}"`)).toThrow(); + // Click on delete button + fireEvent.click(deleteButton); - await waitFor(async () => { - expect(deleteTaxonomy).toBeCalledTimes(1); - }); + // Modal closed + expect(queryByText(`Delete "${taxonomyName}"`)).not.toBeInTheDocument(); - // Toast message shown - expect(mockSetToastMessage).toBeCalledWith(`"${taxonomyName}" deleted`); + await waitFor(async () => { + expect(deleteTaxonomy).toBeCalledTimes(1); }); + + // Toast message shown + expect(mockSetToastMessage).toBeCalledWith(`"${taxonomyName}" deleted`); }); }); diff --git a/src/utils.js b/src/utils.js index 6e5edcee71..47de7412e0 100644 --- a/src/utils.js +++ b/src/utils.js @@ -256,3 +256,22 @@ export const isValidDate = (date) => { return Boolean(formattedValue.length <= 10); }; + +export const getFileSizeToClosestByte = (fileSize, numberOfDivides = 0) => { + if (fileSize > 1000) { + const updatedSize = fileSize / 1000; + const incrementNumberOfDivides = numberOfDivides + 1; + return getFileSizeToClosestByte(updatedSize, incrementNumberOfDivides); + } + const fileSizeFixedDecimal = Number.parseFloat(fileSize).toFixed(2); + switch (numberOfDivides) { + case 1: + return `${fileSizeFixedDecimal} KB`; + case 2: + return `${fileSizeFixedDecimal} MB`; + case 3: + return `${fileSizeFixedDecimal} GB`; + default: + return `${fileSizeFixedDecimal} B`; + } +}; diff --git a/src/files-and-videos/generic/utils.test.js b/src/utils.test.js similarity index 100% rename from src/files-and-videos/generic/utils.test.js rename to src/utils.test.js