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/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/loading-button/LoadingButton.test.jsx b/src/generic/loading-button/LoadingButton.test.jsx
new file mode 100644
index 0000000000..f52d43c3c8
--- /dev/null
+++ b/src/generic/loading-button/LoadingButton.test.jsx
@@ -0,0 +1,76 @@
+import React from 'react';
+import {
+ act,
+ fireEvent,
+ render,
+} from '@testing-library/react';
+
+import LoadingButton from '.';
+
+const buttonTitle = 'Button Title';
+
+const RootWrapper = (onClick) => (
+
+);
+
+describe('', () => {
+ it('renders the title and doesnt handle the spinner initially', () => {
+ const { container, getByText } = render(RootWrapper(() => { }));
+ expect(getByText(buttonTitle)).toBeInTheDocument();
+
+ expect(container.getElementsByClassName('icon-spin').length).toBe(0);
+ });
+
+ it('doesnt render the spinner without onClick function', () => {
+ const { container, getByRole, getByText } = render(RootWrapper());
+ const titleElement = getByText(buttonTitle);
+ expect(titleElement).toBeInTheDocument();
+ expect(container.getElementsByClassName('icon-spin').length).toBe(0);
+ fireEvent.click(getByRole('button'));
+ expect(container.getElementsByClassName('icon-spin').length).toBe(0);
+ });
+
+ it('renders the spinner correctly', async () => {
+ let resolver;
+ const longFunction = () => new Promise((resolve) => {
+ resolver = resolve;
+ });
+ const { container, getByRole, getByText } = render(RootWrapper(longFunction));
+ const buttonElement = getByRole('button');
+ fireEvent.click(buttonElement);
+ expect(container.getElementsByClassName('icon-spin').length).toBe(1);
+ expect(getByText(buttonTitle)).toBeInTheDocument();
+ // StatefulButton only sets aria-disabled (not disabled) when the state is pending
+ // expect(buttonElement).toBeDisabled();
+ expect(buttonElement).toHaveAttribute('aria-disabled', 'true');
+
+ await act(async () => { resolver(); });
+
+ expect(buttonElement).not.toHaveAttribute('aria-disabled', 'true');
+ expect(container.getElementsByClassName('icon-spin').length).toBe(0);
+ });
+
+ it('renders the spinner correctly even with error', async () => {
+ let rejecter;
+ const longFunction = () => new Promise((_resolve, reject) => {
+ rejecter = reject;
+ });
+ const { container, getByRole, getByText } = render(RootWrapper(longFunction));
+ const buttonElement = getByRole('button');
+
+ fireEvent.click(buttonElement);
+
+ expect(container.getElementsByClassName('icon-spin').length).toBe(1);
+ expect(getByText(buttonTitle)).toBeInTheDocument();
+ // StatefulButton only sets aria-disabled (not disabled) when the state is pending
+ // expect(buttonElement).toBeDisabled();
+ expect(buttonElement).toHaveAttribute('aria-disabled', 'true');
+
+ await act(async () => { rejecter(new Error('error')); });
+
+ // StatefulButton only sets aria-disabled (not disabled) when the state is pending
+ // expect(buttonElement).toBeEnabled();
+ expect(buttonElement).not.toHaveAttribute('aria-disabled', 'true');
+ expect(container.getElementsByClassName('icon-spin').length).toBe(0);
+ });
+});
diff --git a/src/generic/loading-button/index.jsx b/src/generic/loading-button/index.jsx
new file mode 100644
index 0000000000..93dca0c974
--- /dev/null
+++ b/src/generic/loading-button/index.jsx
@@ -0,0 +1,72 @@
+// @ts-check
+import React, {
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+import {
+ StatefulButton,
+} from '@edx/paragon';
+import PropTypes from 'prop-types';
+
+/**
+ * A button that shows a loading spinner when clicked.
+ * @param {object} props
+ * @param {string} props.label
+ * @param {function=} props.onClick
+ * @param {boolean=} props.disabled
+ * @returns {JSX.Element}
+ */
+const LoadingButton = ({
+ label,
+ onClick,
+ disabled,
+}) => {
+ const [state, setState] = useState('');
+ // This is used to prevent setting the isLoading state after the component has been unmounted.
+ const componentMounted = useRef(true);
+
+ useEffect(() => () => {
+ componentMounted.current = false;
+ }, []);
+
+ const loadingOnClick = useCallback(async (e) => {
+ if (!onClick) {
+ return;
+ }
+
+ setState('pending');
+ try {
+ await onClick(e);
+ } catch (err) {
+ // Do nothing
+ } finally {
+ if (componentMounted.current) {
+ setState('');
+ }
+ }
+ }, [componentMounted, onClick]);
+
+ return (
+
+ );
+};
+
+LoadingButton.propTypes = {
+ label: PropTypes.string.isRequired,
+ onClick: PropTypes.func,
+ disabled: PropTypes.bool,
+};
+
+LoadingButton.defaultProps = {
+ onClick: undefined,
+ disabled: undefined,
+};
+
+export default LoadingButton;
diff --git a/src/taxonomy/TaxonomyLayout.jsx b/src/taxonomy/TaxonomyLayout.jsx
index 093ee743d3..a54cd23ae1 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);
+ const [toastMessage, setToastMessage] = useState(/** @type{null|string} */ (null));
+ // Use `setToastMessage` to show the alert.
+ const [alertProps, setAlertProps] = useState(/** @type {null|import('./common/context').AlertProps} */ (null));
const context = useMemo(() => ({
- toastMessage, setToastMessage,
+ toastMessage, setToastMessage, alertProps, setAlertProps,
}), []);
return (
+ { alertProps && (
+
setAlertProps(null)}
+ {...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..a372b10257 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,37 @@ 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,
+ queryByTestId,
+ } = 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(queryByTestId('taxonomy-alert')).not.toBeInTheDocument();
});
});
diff --git a/src/taxonomy/TaxonomyListPage.test.jsx b/src/taxonomy/TaxonomyListPage.test.jsx
index 00a960543d..6dc3fe8b14 100644
--- a/src/taxonomy/TaxonomyListPage.test.jsx
+++ b/src/taxonomy/TaxonomyListPage.test.jsx
@@ -39,7 +39,6 @@ const context = {
toastMessage: null,
setToastMessage: jest.fn(),
};
-
const queryClient = new QueryClient();
const RootWrapper = () => (
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/import-tags/ImportTagsWizard.jsx b/src/taxonomy/import-tags/ImportTagsWizard.jsx
new file mode 100644
index 0000000000..315b038b95
--- /dev/null
+++ b/src/taxonomy/import-tags/ImportTagsWizard.jsx
@@ -0,0 +1,419 @@
+// @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 }) => {
+ const intl = useIntl();
+
+ return (
+
+
+ {intl.formatMessage(messages.importWizardStepExportBody, { br: linebreak })}
+
+
+
+
+
+
+ );
+};
+
+ExportStep.propTypes = {
+ taxonomy: TaxonomyProp.isRequired,
+};
+
+const UploadStep = ({
+ file,
+ setFile,
+ importPlanError,
+ setImportPlanError,
+}) => {
+ 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,
+};
+
+UploadStep.defaultProps = {
+ file: null,
+ importPlanError: null,
+};
+
+const PlanStep = ({ importPlan }) => {
+ 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),
+};
+
+PlanStep.defaultProps = {
+ importPlan: null,
+};
+
+const ConfirmStep = ({ importPlan }) => {
+ const intl = useIntl();
+
+ return (
+
+
+ {intl.formatMessage(
+ messages.importWizardStepConfirmBody,
+ { br: linebreak, changeCount: importPlan?.length },
+ )}
+
+
+ );
+};
+
+ConfirmStep.propTypes = {
+ importPlan: PropTypes.arrayOf(PropTypes.string),
+};
+
+ConfirmStep.defaultProps = {
+ importPlan: null,
+};
+
+const DefaultModalHeader = ({ children }) => (
+
+ {children}
+
+);
+
+DefaultModalHeader.propTypes = {
+ children: PropTypes.string.isRequired,
+};
+
+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) {
+ setImportPlan(null);
+ 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 stepHeaders = {
+ 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
+
+ )}
+
+ {stepHeaders[currentStep]}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+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..e61887b31c
--- /dev/null
+++ b/src/taxonomy/import-tags/ImportTagsWizard.test.jsx
@@ -0,0 +1,238 @@
+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();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ queryClient.clear();
+ });
+
+ it('render the dialog in the first step can close on cancel', async () => {
+ const onClose = jest.fn();
+ const { findByTestId, getByTestId } = render();
+
+ expect(await findByTestId('export-step')).toBeInTheDocument();
+
+ fireEvent.click(getByTestId('cancel-button'));
+
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('can export taxonomies from the dialog', async () => {
+ const onClose = jest.fn();
+ const { findByTestId, getByTestId } = render();
+
+ expect(await findByTestId('export-step')).toBeInTheDocument();
+
+ fireEvent.click(getByTestId('export-json-button'));
+
+ expect(getTaxonomyExportFile).toHaveBeenCalledWith(taxonomy.id, 'json');
+
+ fireEvent.click(getByTestId('export-csv-button'));
+
+ expect(getTaxonomyExportFile).toHaveBeenCalledWith(taxonomy.id, 'csv');
+ });
+
+ it.each(['success', 'error'])('can upload taxonomies from the dialog (%p)', async (expectedResult) => {
+ const onClose = jest.fn();
+ const {
+ findByTestId, findByText, getByRole, getAllByTestId, getByTestId, getByText,
+ } = render();
+
+ expect(await findByTestId('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();
+
+ // Continue flow
+ const importButton = getByRole('button', { name: 'Import' });
+ expect(importButton).toHaveAttribute('aria-disabled', 'true');
+
+ // 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(getByTestId('dropzone')).toBeInTheDocument();
+ expect(importButton).toHaveAttribute('aria-disabled', 'true');
+
+ // Correct file type
+ const fileJson = new File(['file contents'], 'example.json', { type: 'application/gzip' });
+ fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [fileJson], types: ['Files'] } });
+ expect(await findByTestId('file-info')).toBeInTheDocument();
+ expect(getByText('example.json')).toBeInTheDocument();
+ expect(importButton).not.toHaveAttribute('aria-disabled', 'true');
+
+ // Clear file
+ fireEvent.click(getByTestId('clear-file-button'));
+ expect(await findByTestId('dropzone')).toBeInTheDocument();
+
+ // Reselect file
+ fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [fileJson], types: ['Files'] } });
+ expect(await findByTestId('file-info')).toBeInTheDocument();
+
+ // Simulate error
+ planImportTags.mockRejectedValueOnce(new Error('Test error'));
+ expect(importButton).not.toHaveAttribute('aria-disabled', 'true');
+ fireEvent.click(importButton);
+
+ // Check error message
+ expect(planImportTags).toHaveBeenCalledWith(taxonomy.id, fileJson);
+ expect(await findByText('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'] } });
+
+ expect(await findByTestId('file-info')).toBeInTheDocument();
+ expect(importButton).not.toHaveAttribute('aria-disabled', 'true');
+
+ 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);
+
+ fireEvent.click(importButton);
+
+ expect(await findByTestId('plan-step')).toBeInTheDocument();
+
+ // Test back button
+ fireEvent.click(getByTestId('back-button'));
+ expect(getByTestId('upload-step')).toBeInTheDocument();
+ planImportTags.mockResolvedValueOnce(expectedPlan);
+ fireEvent.click(getByRole('button', { name: 'Import' }));
+ expect(await findByTestId('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'));
+ }
+
+ const confirmButton = getByRole('button', { name: 'Yes, import file' });
+ await waitFor(() => {
+ expect(confirmButton).not.toHaveAttribute('aria-disabled', 'true');
+ });
+
+ fireEvent.click(confirmButton);
+
+ 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