feat: refined ux update a taxonomy by downloading and uploading [FC-0036] (#732)
This PR improves the import tags functionality for existing taxonomies implemented at #675. Co-authored-by: Jillian <jill@opencraft.com> Co-authored-by: Braden MacDonald <mail@bradenm.com>
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
color: $black;
|
||||
}
|
||||
|
||||
.h-200px {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.mw-300px {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
76
src/generic/loading-button/LoadingButton.test.jsx
Normal file
76
src/generic/loading-button/LoadingButton.test.jsx
Normal file
@@ -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) => (
|
||||
<LoadingButton label={buttonTitle} onClick={onClick} />
|
||||
);
|
||||
|
||||
describe('<LoadingButton />', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
72
src/generic/loading-button/index.jsx
Normal file
72
src/generic/loading-button/index.jsx
Normal file
@@ -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 (
|
||||
<StatefulButton
|
||||
disabledStates={disabled ? [state] : ['pending'] /* StatefulButton doesn't support disabled prop */}
|
||||
onClick={loadingOnClick}
|
||||
labels={{ default: label }}
|
||||
state={state}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
LoadingButton.propTypes = {
|
||||
label: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
LoadingButton.defaultProps = {
|
||||
onClick: undefined,
|
||||
disabled: undefined,
|
||||
};
|
||||
|
||||
export default LoadingButton;
|
||||
@@ -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 (
|
||||
<TaxonomyContext.Provider value={context}>
|
||||
<div className="bg-light-400">
|
||||
<Header isHiddenMainMenu />
|
||||
{ alertProps && (
|
||||
<AlertMessage
|
||||
data-testid="taxonomy-alert"
|
||||
className="mb-0"
|
||||
dismissible
|
||||
closeLabel={intl.formatMessage(messages.taxonomyDismissLabel)}
|
||||
onClose={() => setAlertProps(null)}
|
||||
{...alertProps}
|
||||
/>
|
||||
)}
|
||||
<Outlet />
|
||||
<StudioFooter />
|
||||
<Toast
|
||||
show={toastMessage !== null}
|
||||
onClose={() => setToastMessage(null)}
|
||||
data-testid="taxonomy-toast"
|
||||
>
|
||||
{toastMessage}
|
||||
</Toast>
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
show
|
||||
onClose={() => setToastMessage(null)}
|
||||
data-testid="taxonomy-toast"
|
||||
>
|
||||
{toastMessage}
|
||||
</Toast>
|
||||
)}
|
||||
</div>
|
||||
<ScrollRestoration />
|
||||
</TaxonomyContext.Provider>
|
||||
|
||||
@@ -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 (
|
||||
<div data-testid="mock-content">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setToastMessage(toastMessage)}
|
||||
data-testid="taxonomy-show-toast"
|
||||
>
|
||||
Show Toast
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAlertProps({ title: alertErrorTitle, description: alertErrorDescription })}
|
||||
data-testid="taxonomy-show-alert"
|
||||
>
|
||||
Show Alert
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
jest.mock('../header', () => jest.fn(() => <div data-testid="mock-header" />));
|
||||
jest.mock('@edx/frontend-component-footer', () => ({
|
||||
StudioFooter: jest.fn(() => <div data-testid="mock-footer" />),
|
||||
}));
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
Outlet: jest.fn(() => <div data-testid="mock-content" />),
|
||||
Outlet: () => <MockChildComponent />,
|
||||
ScrollRestoration: jest.fn(() => <div />),
|
||||
}));
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
useState: jest.fn((initial) => {
|
||||
if (initial === null) {
|
||||
return [toastMessage, jest.fn()];
|
||||
}
|
||||
return [initial, jest.fn()];
|
||||
}),
|
||||
}));
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
@@ -49,18 +67,37 @@ describe('<TaxonomyLayout />', async () => {
|
||||
store = initializeStore();
|
||||
});
|
||||
|
||||
it('should render page correctly', async () => {
|
||||
it('should render page correctly', () => {
|
||||
const { getByTestId } = render(<RootWrapper />);
|
||||
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(<RootWrapper />);
|
||||
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(<RootWrapper />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +39,6 @@ const context = {
|
||||
toastMessage: null,
|
||||
setToastMessage: jest.fn(),
|
||||
};
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const RootWrapper = () => (
|
||||
|
||||
@@ -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<React.SetStateAction<null|string>>} */ (null),
|
||||
alertProps: /** @type{null|AlertProps} */ (null),
|
||||
setAlertProps: /** @type{null|React.Dispatch<React.SetStateAction<null|AlertProps>>} */ (null),
|
||||
});
|
||||
|
||||
419
src/taxonomy/import-tags/ImportTagsWizard.jsx
Normal file
419
src/taxonomy/import-tags/ImportTagsWizard.jsx
Normal file
@@ -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 = <> <br /> <br /> </>;
|
||||
|
||||
const TaxonomyProp = PropTypes.shape({
|
||||
id: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
});
|
||||
|
||||
const ExportStep = ({ taxonomy }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<Stepper.Step eventKey="export" title="export">
|
||||
<Stack gap={3} data-testid="export-step">
|
||||
<p>{intl.formatMessage(messages.importWizardStepExportBody, { br: linebreak })}</p>
|
||||
<Stack gap={3} direction="horizontal">
|
||||
<Button
|
||||
iconBefore={Download}
|
||||
variant="outline-primary"
|
||||
onClick={() => getTaxonomyExportFile(taxonomy.id, 'csv')}
|
||||
data-testid="export-csv-button"
|
||||
>
|
||||
{intl.formatMessage(messages.importWizardStepExportCSVButton)}
|
||||
</Button>
|
||||
<Button
|
||||
iconBefore={Download}
|
||||
variant="outline-primary"
|
||||
onClick={() => getTaxonomyExportFile(taxonomy.id, 'json')}
|
||||
data-testid="export-json-button"
|
||||
>
|
||||
{intl.formatMessage(messages.importWizardStepExportJSONButton)}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Stepper.Step eventKey="upload" title="upload" hasError={!!importPlanError}>
|
||||
<Stack gap={3} data-testid="upload-step">
|
||||
<p>{intl.formatMessage(messages.importWizardStepUploadBody, { br: linebreak })}</p>
|
||||
<div>
|
||||
{!file ? (
|
||||
<Dropzone
|
||||
maxSize={100 * 1024 * 1024 /* 100MB */}
|
||||
accept={{
|
||||
'text/csv': ['.csv'],
|
||||
'application/json': ['.json'],
|
||||
}}
|
||||
onProcessUpload={handleFileLoad}
|
||||
data-testid="dropzone"
|
||||
/*
|
||||
className is working on Dropzone: https://github.com/openedx/paragon/pull/2950
|
||||
className="h-200px"
|
||||
*/
|
||||
style={{ height: '200px' }}
|
||||
/>
|
||||
) : (
|
||||
<Stack
|
||||
gap={3}
|
||||
direction="horizontal"
|
||||
className="h-200px border-top p-4 align-items-start flex-wrap"
|
||||
data-testid="file-info"
|
||||
>
|
||||
<Icon src={InsertDriveFile} style={{ height: '48px', width: '48px' }} />
|
||||
<Stack gap={0} className="align-self-start">
|
||||
<div>{file.name}</div>
|
||||
<div className="x-small text-gray-500">{getFileSizeToClosestByte(file.size)}</div>
|
||||
</Stack>
|
||||
<IconButton
|
||||
src={DeleteOutline}
|
||||
iconAs={Icon}
|
||||
alt={intl.formatMessage(messages.importWizardStepUploadClearFile)}
|
||||
variant="secondary"
|
||||
className="ml-auto"
|
||||
onClick={clearFile}
|
||||
data-testid="clear-file-button"
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{importPlanError && <Container className="alert alert-danger">{importPlanError}</Container>}
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Stepper.Step eventKey="plan" title="plan">
|
||||
<Stack gap={3} data-testid="plan-step">
|
||||
{intl.formatMessage(messages.importWizardStepPlanBody, { br: linebreak, changeCount: importPlan?.length })}
|
||||
<ul className="h-200px" style={{ overflow: 'scroll' }}>
|
||||
{importPlan?.length ? (
|
||||
importPlan.map((line) => <li key={line} data-testid="plan-action">{line}</li>)
|
||||
) : (
|
||||
<li>{intl.formatMessage(messages.importWizardStepPlanNoChanges)}</li>
|
||||
)}
|
||||
</ul>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
);
|
||||
};
|
||||
|
||||
PlanStep.propTypes = {
|
||||
importPlan: PropTypes.arrayOf(PropTypes.string),
|
||||
};
|
||||
|
||||
PlanStep.defaultProps = {
|
||||
importPlan: null,
|
||||
};
|
||||
|
||||
const ConfirmStep = ({ importPlan }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<Stepper.Step eventKey="confirm" title="confirm">
|
||||
<Stack data-testid="confirm-step">
|
||||
{intl.formatMessage(
|
||||
messages.importWizardStepConfirmBody,
|
||||
{ br: linebreak, changeCount: importPlan?.length },
|
||||
)}
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
);
|
||||
};
|
||||
|
||||
ConfirmStep.propTypes = {
|
||||
importPlan: PropTypes.arrayOf(PropTypes.string),
|
||||
};
|
||||
|
||||
ConfirmStep.defaultProps = {
|
||||
importPlan: null,
|
||||
};
|
||||
|
||||
const DefaultModalHeader = ({ children }) => (
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title>{children}</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
);
|
||||
|
||||
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: (
|
||||
<DefaultModalHeader>
|
||||
{intl.formatMessage(messages.importWizardStepExportTitle, { name: taxonomy.name })}
|
||||
</DefaultModalHeader>
|
||||
),
|
||||
upload: (
|
||||
<DefaultModalHeader>
|
||||
{intl.formatMessage(messages.importWizardStepUploadTitle)}
|
||||
</DefaultModalHeader>
|
||||
),
|
||||
plan: (
|
||||
<DefaultModalHeader>
|
||||
{intl.formatMessage(messages.importWizardStepPlanTitle)}
|
||||
</DefaultModalHeader>
|
||||
),
|
||||
confirm: (
|
||||
<ModalDialog.Header className="bg-warning-100">
|
||||
<Stack gap={2} direction="horizontal">
|
||||
<Icon src={Warning} className="text-warning" />
|
||||
<ModalDialog.Title>
|
||||
{intl.formatMessage(messages.importWizardStepConfirmTitle, { changeCount: importPlan?.length })}
|
||||
</ModalDialog.Title>
|
||||
</Stack>
|
||||
</ModalDialog.Header>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<Container
|
||||
onClick={(e) => e.stopPropagation() /* This prevents calling onClick handler from the parent */}
|
||||
>
|
||||
<ModalDialog
|
||||
title=""
|
||||
isOpen={isOpen}
|
||||
isBlocking
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
>
|
||||
{isDialogDisabled && (
|
||||
// This div is used to prevent the user from interacting with the dialog while it is disabled
|
||||
<div className="position-absolute w-100 h-100 d-block zindex-9" />
|
||||
)}
|
||||
|
||||
{stepHeaders[currentStep]}
|
||||
|
||||
<hr className="mx-4" />
|
||||
|
||||
<Stepper activeKey={currentStep}>
|
||||
<ModalDialog.Body>
|
||||
<ExportStep taxonomy={taxonomy} />
|
||||
<UploadStep
|
||||
file={file}
|
||||
setFile={setFile}
|
||||
importPlanError={importPlanError}
|
||||
setImportPlanError={setImportPlanError}
|
||||
/>
|
||||
<PlanStep importPlan={importPlan} />
|
||||
<ConfirmStep importPlan={importPlan} />
|
||||
</ModalDialog.Body>
|
||||
|
||||
<hr className="mx-4" />
|
||||
|
||||
<ModalDialog.Footer>
|
||||
|
||||
<Stepper.ActionRow eventKey="export">
|
||||
<Button variant="tertiary" onClick={onClose} data-testid="cancel-button">
|
||||
{intl.formatMessage(messages.importWizardButtonCancel)}
|
||||
</Button>
|
||||
<Button onClick={() => setCurrentStep('upload')} data-testid="next-button">
|
||||
{intl.formatMessage(messages.importWizardButtonNext)}
|
||||
</Button>
|
||||
</Stepper.ActionRow>
|
||||
|
||||
<Stepper.ActionRow eventKey="upload">
|
||||
<Button variant="outline-primary" onClick={() => setCurrentStep('export')} data-testid="back-button">
|
||||
{intl.formatMessage(messages.importWizardButtonPrevious)}
|
||||
</Button>
|
||||
<Stepper.ActionRow.Spacer />
|
||||
<Button variant="tertiary" onClick={onClose}>
|
||||
{intl.formatMessage(messages.importWizardButtonCancel)}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
label={intl.formatMessage(messages.importWizardButtonImport)}
|
||||
disabled={!file || !!importPlanError}
|
||||
onClick={generatePlan}
|
||||
/>
|
||||
</Stepper.ActionRow>
|
||||
|
||||
<Stepper.ActionRow eventKey="plan">
|
||||
<Button variant="outline-primary" onClick={() => setCurrentStep('upload')} data-testid="back-button">
|
||||
{intl.formatMessage(messages.importWizardButtonPrevious)}
|
||||
</Button>
|
||||
<Stepper.ActionRow.Spacer />
|
||||
<Button variant="tertiary" onClick={onClose}>
|
||||
{intl.formatMessage(messages.importWizardButtonCancel)}
|
||||
</Button>
|
||||
<Button disabled={!importPlan?.length} onClick={() => setCurrentStep('confirm')} data-testid="continue-button">
|
||||
{intl.formatMessage(messages.importWizardButtonContinue)}
|
||||
</Button>
|
||||
</Stepper.ActionRow>
|
||||
|
||||
<Stepper.ActionRow eventKey="confirm">
|
||||
<Button variant="outline-primary" onClick={() => setCurrentStep('plan')} data-testid="back-button">
|
||||
{intl.formatMessage(messages.importWizardButtonPrevious)}
|
||||
</Button>
|
||||
<Stepper.ActionRow.Spacer />
|
||||
<Button variant="tertiary" onClick={onClose}>
|
||||
{intl.formatMessage(messages.importWizardButtonCancel)}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
label={intl.formatMessage(messages.importWizardButtonConfirm)}
|
||||
onClick={confirmImportTags}
|
||||
/>
|
||||
</Stepper.ActionRow>
|
||||
|
||||
</ModalDialog.Footer>
|
||||
</Stepper>
|
||||
</ModalDialog>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
ImportTagsWizard.propTypes = {
|
||||
taxonomy: TaxonomyProp.isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ImportTagsWizard;
|
||||
238
src/taxonomy/import-tags/ImportTagsWizard.test.jsx
Normal file
238
src/taxonomy/import-tags/ImportTagsWizard.test.jsx
Normal file
@@ -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 }) => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TaxonomyContext.Provider value={context}>
|
||||
<ImportTagsWizard taxonomy={taxonomy} isOpen onClose={onClose} />
|
||||
</TaxonomyContext.Provider>
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
RootWrapper.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
describe('<ImportTagsWizard />', () => {
|
||||
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(<RootWrapper onClose={onClose} />);
|
||||
|
||||
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(<RootWrapper onClose={onClose} />);
|
||||
|
||||
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(<RootWrapper onClose={onClose} />);
|
||||
|
||||
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',
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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<Object>}
|
||||
* @returns {Promise<import('../../data/types.mjs').TaxonomyData>}
|
||||
*/
|
||||
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<Object>}
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
88
src/taxonomy/import-tags/data/api.test.jsx
Normal file
88
src/taxonomy/import-tags/data/api.test.jsx
Normal file
@@ -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 }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,2 +1,3 @@
|
||||
// @ts-check
|
||||
export { importTaxonomyTags, importTaxonomy } from './data/utils';
|
||||
export { importTaxonomy } from './data/utils';
|
||||
export { default as ImportTagsWizard } from './ImportTagsWizard';
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
<ImportTagsWizard
|
||||
taxonomy={taxonomy}
|
||||
isOpen={isImportModalOpen}
|
||||
onClose={importModalClose}
|
||||
/>
|
||||
)}
|
||||
{isManageOrgsModalOpen && (
|
||||
<ManageOrgsModal
|
||||
isOpen={isManageOrgsModalOpen}
|
||||
|
||||
@@ -9,17 +9,12 @@ import PropTypes from 'prop-types';
|
||||
import { TaxonomyContext } from '../common/context';
|
||||
import initializeStore from '../../store';
|
||||
import { deleteTaxonomy, getTaxonomy, getTaxonomyExportFile } from '../data/api';
|
||||
import { importTaxonomyTags } from '../import-tags';
|
||||
import { TaxonomyMenu } from '.';
|
||||
|
||||
let store;
|
||||
const taxonomyId = 1;
|
||||
const taxonomyName = 'Taxonomy 1';
|
||||
|
||||
jest.mock('../import-tags', () => ({
|
||||
importTaxonomyTags: jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
jest.mock('../data/api', () => ({
|
||||
...jest.requireActual('../data/api'),
|
||||
getTaxonomyExportFile: jest.fn(),
|
||||
@@ -169,13 +164,13 @@ describe.each([true, false])('<TaxonomyMenu iconMenu=%s />', async (iconMenu) =>
|
||||
});
|
||||
|
||||
test('should call import tags when menu click', () => {
|
||||
const { getByTestId } = render(<TaxonomyMenuComponent iconMenu={iconMenu} />);
|
||||
const { getByTestId, getByText } = render(<TaxonomyMenuComponent iconMenu={iconMenu} />);
|
||||
|
||||
// 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', () => {
|
||||
|
||||
12
src/utils.js
12
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]}`;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user