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} + * @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 179ca7e816..71377ac72d 100644 --- a/src/taxonomy/import-tags/data/utils.js +++ b/src/taxonomy/import-tags/data/utils.js @@ -1,6 +1,6 @@ // @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 @@ -38,7 +38,7 @@ const selectFile = async () => new Promise((resolve) => { }); /* istanbul ignore next */ -export const importTaxonomy = async (intl) => { +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 @@ -91,33 +91,3 @@ export const importTaxonomy = async (intl) => { console.error(error.response); }); }; - -/* istanbul ignore next */ -export const importTaxonomyTags = async (taxonomyId, intl) => { - /* - * This function is a temporary "Barebones" implementation of the import - * functionality with `confirm` and `alert`. It is intended to be replaced - * with a component that shows a `ModalDialog` in the future. - * See: https://github.com/openedx/modular-learning/issues/126 - */ - /* eslint-disable no-alert */ - /* eslint-disable no-console */ - const file = await selectFile(); - - if (!file) { - return; - } - - if (!window.confirm(intl.formatMessage(messages.confirmImportTags))) { - return; - } - - importTags(taxonomyId, file) - .then(() => { - alert(intl.formatMessage(messages.importTaxonomySuccess)); - }) - .catch((error) => { - alert(intl.formatMessage(messages.importTaxonomyError)); - console.error(error.response); - }); -}; diff --git a/src/taxonomy/import-tags/data/utils.test.js b/src/taxonomy/import-tags/data/utils.test.js 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..05e4eb7388 100644 --- a/src/taxonomy/import-tags/messages.js +++ b/src/taxonomy/import-tags/messages.js @@ -1,7 +1,89 @@ -// 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 export 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', + }, + importWizardStepUploadClearFile: { + id: 'course-authoring.import-tags.wizard.step-upload.clear-file', + defaultMessage: 'Clear 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 +104,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/messages.js b/src/taxonomy/messages.js index 7987eb525d..045b0841f8 100644 --- a/src/taxonomy/messages.js +++ b/src/taxonomy/messages.js @@ -45,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-menu/TaxonomyMenu.jsx b/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx index 730bc796e0..c1d15da5a3 100644 --- a/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx +++ b/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx @@ -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 { ManageOrgsModal } from '../manage-orgs'; import messages from './messages'; @@ -46,6 +46,7 @@ const TaxonomyMenu = ({ const [isDeleteDialogOpen, deleteDialogOpen, deleteDialogClose] = useToggle(false); const [isExportModalOpen, exportModalOpen, exportModalClose] = useToggle(false); + const [isImportModalOpen, importModalOpen, importModalClose] = useToggle(false); const [isManageOrgsModalOpen, manageOrgsModalOpen, manageOrgsModalClose] = useToggle(false); /** @@ -60,7 +61,7 @@ const TaxonomyMenu = ({ let menuItems = { import: { title: intl.formatMessage(messages.importMenu), - action: () => importTaxonomyTags(taxonomy.id, intl), + action: importModalOpen, // Hide import menu item if taxonomy is system defined or allows free text hide: taxonomy.systemDefined || taxonomy.allowFreeText, }, @@ -103,6 +104,13 @@ const TaxonomyMenu = ({ taxonomyId={taxonomy.id} /> )} + {isImportModalOpen && ( + + )} {isManageOrgsModalOpen && ( ({ - importTaxonomyTags: jest.fn().mockResolvedValue({}), -})); - jest.mock('../data/api', () => ({ ...jest.requireActual('../data/api'), getTaxonomyExportFile: jest.fn(), @@ -169,13 +164,13 @@ describe.each([true, false])('', async (iconMenu) => }); test('should call import tags when menu click', () => { - const { getByTestId } = render(); + const { getByTestId, getByText } = render(); // Click on import menu fireEvent.click(getByTestId('taxonomy-menu-button')); fireEvent.click(getByTestId('taxonomy-menu-import')); - expect(importTaxonomyTags).toHaveBeenCalled(); + expect(getByText('Update "Taxonomy 1"')).toBeInTheDocument(); }); test('should export a taxonomy', () => { diff --git a/src/utils.js b/src/utils.js index 6e5edcee71..8178e754eb 100644 --- a/src/utils.js +++ b/src/utils.js @@ -256,3 +256,15 @@ export const isValidDate = (date) => { return Boolean(formattedValue.length <= 10); }; + +export const getFileSizeToClosestByte = (fileSize) => { + let divides = 0; + let size = fileSize; + while (size > 1000 && divides < 4) { + size /= 1000; + divides += 1; + } + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const fileSizeFixedDecimal = Number.parseFloat(size).toFixed(2); + return `${fileSizeFixedDecimal} ${units[divides]}`; +}; diff --git a/src/files-and-videos/generic/utils.test.js b/src/utils.test.js similarity index 68% rename from src/files-and-videos/generic/utils.test.js rename to src/utils.test.js index f2e9995dd9..3c0ddabf7f 100644 --- a/src/files-and-videos/generic/utils.test.js +++ b/src/utils.test.js @@ -22,5 +22,15 @@ describe('FilesAndUploads utils', () => { const actualSize = getFileSizeToClosestByte(2034190000); expect(expectedSize).toEqual(actualSize); }); + it('should return file size with TB for terabytes', () => { + const expectedSize = '1.99 TB'; + const actualSize = getFileSizeToClosestByte(1988034190000); + expect(expectedSize).toEqual(actualSize); + }); + it('should return file size with TB for larger numbers', () => { + const expectedSize = '1234.56 TB'; + const actualSize = getFileSizeToClosestByte(1234560000000000); + expect(expectedSize).toEqual(actualSize); + }); }); });