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:
Rômulo Penido
2024-01-16 03:30:15 -03:00
committed by GitHub
parent 1fef358f55
commit b59ecafc83
26 changed files with 1190 additions and 460 deletions

View File

@@ -2,6 +2,10 @@
color: $black;
}
.h-200px {
height: 200px;
}
.mw-300px {
max-width: 300px;
}

View File

@@ -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 = ({

View File

@@ -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';

View File

@@ -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') {

View File

@@ -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';

View 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);
});
});

View 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;

View File

@@ -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>

View File

@@ -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();
});
});

View File

@@ -39,7 +39,6 @@ const context = {
toastMessage: null,
setToastMessage: jest.fn(),
};
const queryClient = new QueryClient();
const RootWrapper = () => (

View File

@@ -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),
});

View 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;

View 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',
}),
);
}
});
});

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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);
});
});

View 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));
});
});

View File

@@ -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);
});
};

View File

@@ -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;
});
});
});

View File

@@ -1,2 +1,3 @@
// @ts-check
export { importTaxonomyTags, importTaxonomy } from './data/utils';
export { importTaxonomy } from './data/utils';
export { default as ImportTagsWizard } from './ImportTagsWizard';

View File

@@ -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',
},
});

View File

@@ -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;

View File

@@ -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}

View File

@@ -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', () => {

View File

@@ -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]}`;
};

View File

@@ -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);
});
});
});