({
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 = [
+ ,
+ ,
+ ];
+
+ if (isOrganizationListLoaded && organizationListData) {
+ organizationListData.forEach(org => (
+ selectOptions.push(
+ ,
+ )
+ ));
+ }
+
+ 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