feat: Import new taxonomy dialog flow (#1017)
This PR updates the existing import tags wizard to also handle importing new taxonomies.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// @ts-check
|
||||
import React, { useCallback, useContext, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Button,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Tooltip,
|
||||
SelectMenu,
|
||||
MenuItem,
|
||||
useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
Add,
|
||||
@@ -25,33 +26,24 @@ import { useOrganizationListData } from '../generic/data/apiHooks';
|
||||
import SubHeader from '../generic/sub-header/SubHeader';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
import { ALL_TAXONOMIES, apiUrls, UNASSIGNED } from './data/api';
|
||||
import { useImportNewTaxonomy, useTaxonomyList } from './data/apiHooks';
|
||||
import { importTaxonomy } from './import-tags';
|
||||
import { useTaxonomyList } from './data/apiHooks';
|
||||
import { ImportTagsWizard } from './import-tags';
|
||||
import messages from './messages';
|
||||
import TaxonomyCard from './taxonomy-card';
|
||||
import { TaxonomyContext } from './common/context';
|
||||
|
||||
const TaxonomyListHeaderButtons = ({ canAddTaxonomy }) => {
|
||||
const intl = useIntl();
|
||||
const importMutation = useImportNewTaxonomy();
|
||||
const { setToastMessage } = useContext(TaxonomyContext);
|
||||
|
||||
const showImportInProgressAlert = useCallback(() => {
|
||||
/* istanbul ignore next */
|
||||
if (setToastMessage) {
|
||||
setToastMessage(intl.formatMessage(messages.importInProgressAlertDescription));
|
||||
}
|
||||
}, [setToastMessage]);
|
||||
|
||||
const closeImportInProgressAlert = useCallback(() => {
|
||||
/* istanbul ignore next */
|
||||
if (setToastMessage) {
|
||||
setToastMessage(null);
|
||||
}
|
||||
}, [setToastMessage]);
|
||||
const [isImportModalOpen, importModalOpen, importModalClose] = useToggle(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isImportModalOpen && (
|
||||
<ImportTagsWizard
|
||||
isOpen={isImportModalOpen}
|
||||
onClose={importModalClose}
|
||||
/>
|
||||
)}
|
||||
<OverlayTrigger
|
||||
placement="top"
|
||||
overlay={(
|
||||
@@ -86,12 +78,7 @@ const TaxonomyListHeaderButtons = ({ canAddTaxonomy }) => {
|
||||
</OverlayTrigger>
|
||||
<Button
|
||||
iconBefore={Add}
|
||||
onClick={() => importTaxonomy(
|
||||
intl,
|
||||
importMutation,
|
||||
showImportInProgressAlert,
|
||||
closeImportInProgressAlert,
|
||||
)}
|
||||
onClick={importModalOpen}
|
||||
data-testid="taxonomy-import-button"
|
||||
disabled={!canAddTaxonomy}
|
||||
>
|
||||
|
||||
@@ -15,7 +15,6 @@ import MockAdapter from 'axios-mock-adapter';
|
||||
import initializeStore from '../store';
|
||||
import { apiUrls } from './data/api';
|
||||
import TaxonomyListPage from './TaxonomyListPage';
|
||||
import { importTaxonomy } from './import-tags';
|
||||
import { TaxonomyContext } from './common/context';
|
||||
|
||||
let store;
|
||||
@@ -37,10 +36,6 @@ const listTaxonomiesOrg1Url = `${listTaxonomiesUrl}&org=Org+1`;
|
||||
const listTaxonomiesOrg2Url = `${listTaxonomiesUrl}&org=Org+2`;
|
||||
const organizations = ['Org 1', 'Org 2'];
|
||||
|
||||
jest.mock('./import-tags', () => ({
|
||||
importTaxonomy: jest.fn(),
|
||||
}));
|
||||
|
||||
const context = {
|
||||
toastMessage: null,
|
||||
setToastMessage: jest.fn(),
|
||||
@@ -125,15 +120,16 @@ describe('<TaxonomyListPage />', () => {
|
||||
expect(importButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('calls the import taxonomy action when the import button is clicked', async () => {
|
||||
it('opens the import dialog modal when the import button is clicked', async () => {
|
||||
axiosMock.onGet(listTaxonomiesUrl).reply(200, { results: [], canAddTaxonomy: true });
|
||||
|
||||
const { getByRole } = render(<RootWrapper />);
|
||||
const { getByRole, getByText } = render(<RootWrapper />);
|
||||
const importButton = getByRole('button', { name: 'Import' });
|
||||
// Once the API response is received and rendered, the Import button should be enabled:
|
||||
await waitFor(() => { expect(importButton).not.toBeDisabled(); });
|
||||
fireEvent.click(importButton);
|
||||
expect(importTaxonomy).toHaveBeenCalled();
|
||||
|
||||
expect(getByText('Upload file')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show all "All taxonomies", "Unassigned" and org names in taxonomy org filter', async () => {
|
||||
|
||||
@@ -187,7 +187,7 @@ export const useImportPlan = (taxonomyId, file) => useQuery({
|
||||
* @type {import("@tanstack/react-query").QueryFunction<string|null>}
|
||||
*/
|
||||
queryFn: async () => {
|
||||
if (file === null) {
|
||||
if (!taxonomyId || file === null) {
|
||||
return null;
|
||||
}
|
||||
const formData = new FormData();
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ModalDialog,
|
||||
Stack,
|
||||
Stepper,
|
||||
Form,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
DeleteOutline,
|
||||
@@ -25,8 +26,8 @@ import LoadingButton from '../../generic/loading-button';
|
||||
import { LoadingSpinner } from '../../generic/Loading';
|
||||
import { getFileSizeToClosestByte } from '../../utils';
|
||||
import { TaxonomyContext } from '../common/context';
|
||||
import { getTaxonomyExportFile } from '../data/api';
|
||||
import { useImportTags, useImportPlan } from '../data/apiHooks';
|
||||
import { getTaxonomyExportFile, apiUrls } from '../data/api';
|
||||
import { useImportTags, useImportPlan, useImportNewTaxonomy } from '../data/apiHooks';
|
||||
import messages from './messages';
|
||||
|
||||
const linebreak = <> <br /> <br /> </>;
|
||||
@@ -74,9 +75,18 @@ const UploadStep = ({
|
||||
file,
|
||||
setFile,
|
||||
importPlanError,
|
||||
reimport,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const csvTemplateUrl = (
|
||||
<a href={apiUrls.taxonomyTemplate('csv')} download>{intl.formatMessage(messages.csvTemplateTitle)}</a>
|
||||
);
|
||||
|
||||
const jsonTemplateUrl = (
|
||||
<a href={apiUrls.taxonomyTemplate('json')} download>{intl.formatMessage(messages.jsonTemplateTitle)}</a>
|
||||
);
|
||||
|
||||
/** @type {(args: {fileData: FormData}) => void} */
|
||||
const handleFileLoad = ({ fileData }) => {
|
||||
setFile(fileData.get('file'));
|
||||
@@ -90,7 +100,15 @@ const UploadStep = ({
|
||||
return (
|
||||
<Stepper.Step eventKey="upload" title="upload" hasError={!!importPlanError}>
|
||||
<Stack gap={3} data-testid="upload-step">
|
||||
<p>{intl.formatMessage(messages.importWizardStepUploadBody, { br: linebreak })}</p>
|
||||
<p>{
|
||||
reimport
|
||||
? intl.formatMessage(messages.importWizardStepReuploadBody, { br: linebreak })
|
||||
: intl.formatMessage(
|
||||
messages.importWizardStepUploadBody,
|
||||
{ csvTemplateUrl, jsonTemplateUrl, br: linebreak },
|
||||
)
|
||||
}
|
||||
</p>
|
||||
<div>
|
||||
{!file ? (
|
||||
<Dropzone
|
||||
@@ -145,11 +163,60 @@ UploadStep.propTypes = {
|
||||
}),
|
||||
setFile: PropTypes.func.isRequired,
|
||||
importPlanError: PropTypes.string,
|
||||
reimport: PropTypes.bool,
|
||||
};
|
||||
|
||||
UploadStep.defaultProps = {
|
||||
file: null,
|
||||
importPlanError: null,
|
||||
reimport: false,
|
||||
};
|
||||
|
||||
const PopulateStep = ({
|
||||
taxonomyPopulateData,
|
||||
setTaxonomyPopulateData,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const handleNameChange = (e) => {
|
||||
const updatedState = { ...taxonomyPopulateData };
|
||||
updatedState.taxonomyName = e.target.value;
|
||||
setTaxonomyPopulateData(updatedState);
|
||||
};
|
||||
|
||||
const handleDescChange = (e) => {
|
||||
const updatedState = { ...taxonomyPopulateData };
|
||||
updatedState.taxonomyDesc = e.target.value;
|
||||
setTaxonomyPopulateData(updatedState);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stepper.Step eventKey="populate" title="populate">
|
||||
<Stack gap={3} data-testid="populate-step">
|
||||
<Form.Group>
|
||||
<Form.Label>{ intl.formatMessage(messages.importWizardStepPopulateTaxonomyName) }</Form.Label>
|
||||
<Form.Control value={taxonomyPopulateData.taxonomyName} onChange={handleNameChange} />
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Label>{ intl.formatMessage(messages.importWizardStepPopulateTaxonomyDesc) }</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
autoResize
|
||||
value={taxonomyPopulateData.taxonomyDesc}
|
||||
onChange={handleDescChange}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
);
|
||||
};
|
||||
|
||||
PopulateStep.propTypes = {
|
||||
taxonomyPopulateData: PropTypes.shape({
|
||||
taxonomyName: PropTypes.string.isRequired,
|
||||
taxonomyDesc: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
setTaxonomyPopulateData: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const PlanStep = ({ importPlan }) => {
|
||||
@@ -216,18 +283,56 @@ const ImportTagsWizard = ({
|
||||
taxonomy,
|
||||
isOpen,
|
||||
onClose,
|
||||
reimport,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { setToastMessage, setAlertProps } = useContext(TaxonomyContext);
|
||||
|
||||
const steps = ['export', 'upload', 'plan', 'confirm'];
|
||||
const [currentStep, setCurrentStep] = useState(steps[0]);
|
||||
const [currentStep, setCurrentStep] = useState(reimport ? 'export' : 'upload');
|
||||
|
||||
const [file, setFile] = useState(/** @type {null|File} */ (null));
|
||||
|
||||
const [isDialogDisabled, disableDialog, enableDialog] = useToggle(false);
|
||||
|
||||
const importPlanResult = useImportPlan(taxonomy.id, file);
|
||||
const [taxonomyPopulateData, setTaxonomyPopulateData] = useState({
|
||||
taxonomyName: '',
|
||||
taxonomyDesc: '',
|
||||
});
|
||||
|
||||
const importNewTaxonomyMutation = useImportNewTaxonomy();
|
||||
|
||||
const importNewTaxonomy = async () => {
|
||||
disableDialog();
|
||||
try {
|
||||
const { taxonomyName, taxonomyDesc } = taxonomyPopulateData;
|
||||
if (file) {
|
||||
await importNewTaxonomyMutation.mutateAsync({
|
||||
name: taxonomyName,
|
||||
description: taxonomyDesc,
|
||||
file,
|
||||
});
|
||||
}
|
||||
if (setToastMessage) {
|
||||
setToastMessage(intl.formatMessage(messages.importNewTaxonomyToast, { name: taxonomyName }));
|
||||
}
|
||||
} 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 importPlanResult = useImportPlan(taxonomy?.id, file);
|
||||
|
||||
const importPlan = useMemo(() => {
|
||||
if (!importPlanResult.data) {
|
||||
@@ -248,6 +353,10 @@ const ImportTagsWizard = ({
|
||||
setCurrentStep('plan');
|
||||
}, []);
|
||||
|
||||
const populateData = React.useCallback(() => {
|
||||
setCurrentStep('populate');
|
||||
}, []);
|
||||
|
||||
const confirmImportTags = async () => {
|
||||
disableDialog();
|
||||
try {
|
||||
@@ -258,7 +367,7 @@ const ImportTagsWizard = ({
|
||||
});
|
||||
}
|
||||
if (setToastMessage) {
|
||||
setToastMessage(intl.formatMessage(messages.importTaxonomyToast, { name: taxonomy.name }));
|
||||
setToastMessage(intl.formatMessage(messages.importTaxonomyToast, { name: taxonomy?.name }));
|
||||
}
|
||||
} catch (/** @type {any} */ error) {
|
||||
const alertProps = {
|
||||
@@ -280,7 +389,7 @@ const ImportTagsWizard = ({
|
||||
const stepHeaders = {
|
||||
export: (
|
||||
<DefaultModalHeader>
|
||||
{intl.formatMessage(messages.importWizardStepExportTitle, { name: taxonomy.name })}
|
||||
{intl.formatMessage(messages.importWizardStepExportTitle, { name: taxonomy?.name })}
|
||||
</DefaultModalHeader>
|
||||
),
|
||||
upload: (
|
||||
@@ -288,6 +397,11 @@ const ImportTagsWizard = ({
|
||||
{intl.formatMessage(messages.importWizardStepUploadTitle)}
|
||||
</DefaultModalHeader>
|
||||
),
|
||||
populate: (
|
||||
<DefaultModalHeader>
|
||||
{intl.formatMessage(messages.importWizardStepPopulateTitle)}
|
||||
</DefaultModalHeader>
|
||||
),
|
||||
plan: (
|
||||
<DefaultModalHeader>
|
||||
{intl.formatMessage(messages.importWizardStepPlanTitle)}
|
||||
@@ -327,11 +441,16 @@ const ImportTagsWizard = ({
|
||||
|
||||
<Stepper activeKey={currentStep}>
|
||||
<ModalDialog.Body>
|
||||
<ExportStep taxonomy={taxonomy} />
|
||||
{reimport && <ExportStep taxonomy={taxonomy} />}
|
||||
<UploadStep
|
||||
file={file}
|
||||
setFile={setFile}
|
||||
importPlanError={/** @type {Error|undefined} */(importPlanResult.error)?.message}
|
||||
reimport={reimport}
|
||||
/>
|
||||
<PopulateStep
|
||||
taxonomyPopulateData={taxonomyPopulateData}
|
||||
setTaxonomyPopulateData={setTaxonomyPopulateData}
|
||||
/>
|
||||
<PlanStep importPlan={importPlan} />
|
||||
<ConfirmStep importPlan={importPlan} />
|
||||
@@ -351,9 +470,14 @@ const ImportTagsWizard = ({
|
||||
</Stepper.ActionRow>
|
||||
|
||||
<Stepper.ActionRow eventKey="upload">
|
||||
<Button variant="outline-primary" onClick={() => setCurrentStep('export')} data-testid="back-button">
|
||||
{intl.formatMessage(messages.importWizardButtonPrevious)}
|
||||
</Button>
|
||||
{
|
||||
reimport
|
||||
&& (
|
||||
<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)}
|
||||
@@ -362,14 +486,35 @@ const ImportTagsWizard = ({
|
||||
importPlanResult.isLoading ? <LoadingSpinner />
|
||||
: (
|
||||
<LoadingButton
|
||||
label={intl.formatMessage(messages.importWizardButtonImport)}
|
||||
label={
|
||||
reimport
|
||||
? intl.formatMessage(messages.importWizardButtonImport)
|
||||
: intl.formatMessage(messages.importWizardButtonContinue)
|
||||
}
|
||||
disabled={!file || importPlanResult.isLoading || !!importPlanResult.error}
|
||||
onClick={generatePlan}
|
||||
onClick={reimport ? generatePlan : populateData}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Stepper.ActionRow>
|
||||
|
||||
<Stepper.ActionRow eventKey="populate">
|
||||
<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>
|
||||
<LoadingButton
|
||||
label={intl.formatMessage(messages.importWizardButtonImport)}
|
||||
disabled={!taxonomyPopulateData.taxonomyName || !taxonomyPopulateData.taxonomyDesc}
|
||||
onClick={importNewTaxonomy}
|
||||
data-testid="import-button"
|
||||
/>
|
||||
|
||||
</Stepper.ActionRow>
|
||||
|
||||
<Stepper.ActionRow eventKey="plan">
|
||||
<Button variant="outline-primary" onClick={() => setCurrentStep('upload')} data-testid="back-button">
|
||||
{intl.formatMessage(messages.importWizardButtonPrevious)}
|
||||
@@ -384,9 +529,14 @@ const ImportTagsWizard = ({
|
||||
</Stepper.ActionRow>
|
||||
|
||||
<Stepper.ActionRow eventKey="confirm">
|
||||
<Button variant="outline-primary" onClick={() => setCurrentStep('plan')} data-testid="back-button">
|
||||
{intl.formatMessage(messages.importWizardButtonPrevious)}
|
||||
</Button>
|
||||
{
|
||||
reimport
|
||||
&& (
|
||||
<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)}
|
||||
@@ -404,10 +554,16 @@ const ImportTagsWizard = ({
|
||||
);
|
||||
};
|
||||
|
||||
ImportTagsWizard.defaultProps = {
|
||||
taxonomy: null,
|
||||
reimport: false,
|
||||
};
|
||||
|
||||
ImportTagsWizard.propTypes = {
|
||||
taxonomy: TaxonomyProp.isRequired,
|
||||
taxonomy: TaxonomyProp,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
reimport: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default ImportTagsWizard;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
fireEvent,
|
||||
render,
|
||||
waitFor,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
@@ -39,18 +40,19 @@ const context = {
|
||||
|
||||
const planImportUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/1/tags/import/plan/';
|
||||
const doImportUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/1/tags/import/';
|
||||
const doImportNewTaxonomyUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/import/';
|
||||
|
||||
const taxonomy = {
|
||||
const sampleTaxonomy = {
|
||||
id: 1,
|
||||
name: 'Test Taxonomy',
|
||||
};
|
||||
|
||||
const RootWrapper = ({ onClose }) => (
|
||||
const RootWrapper = ({ onClose, reimport, taxonomy }) => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TaxonomyContext.Provider value={context}>
|
||||
<ImportTagsWizard taxonomy={taxonomy} isOpen onClose={onClose} />
|
||||
<ImportTagsWizard taxonomy={taxonomy} isOpen onClose={onClose} reimport={reimport} />
|
||||
</TaxonomyContext.Provider>
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
@@ -59,6 +61,11 @@ const RootWrapper = ({ onClose }) => (
|
||||
|
||||
RootWrapper.propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
reimport: PropTypes.bool.isRequired,
|
||||
taxonomy: PropTypes.shape({
|
||||
id: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
describe('<ImportTagsWizard />', () => {
|
||||
@@ -80,9 +87,9 @@ describe('<ImportTagsWizard />', () => {
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it('render the dialog in the first step can close on cancel', async () => {
|
||||
it('render the dialog in the reimport first step can close on cancel', async () => {
|
||||
const onClose = jest.fn();
|
||||
const { findByTestId, getByTestId } = render(<RootWrapper onClose={onClose} />);
|
||||
const { findByTestId, getByTestId } = render(<RootWrapper taxonomy={sampleTaxonomy} onClose={onClose} reimport />);
|
||||
|
||||
expect(await findByTestId('export-step')).toBeInTheDocument();
|
||||
|
||||
@@ -91,26 +98,26 @@ describe('<ImportTagsWizard />', () => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('can export taxonomies from the dialog', async () => {
|
||||
it('can export taxonomies from the reimport dialog', async () => {
|
||||
const onClose = jest.fn();
|
||||
const { findByTestId, getByTestId } = render(<RootWrapper onClose={onClose} />);
|
||||
const { findByTestId, getByTestId } = render(<RootWrapper taxonomy={sampleTaxonomy} onClose={onClose} reimport />);
|
||||
|
||||
expect(await findByTestId('export-step')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(getByTestId('export-json-button'));
|
||||
|
||||
expect(getTaxonomyExportFile).toHaveBeenCalledWith(taxonomy.id, 'json');
|
||||
expect(getTaxonomyExportFile).toHaveBeenCalledWith(sampleTaxonomy.id, 'json');
|
||||
|
||||
fireEvent.click(getByTestId('export-csv-button'));
|
||||
|
||||
expect(getTaxonomyExportFile).toHaveBeenCalledWith(taxonomy.id, 'csv');
|
||||
expect(getTaxonomyExportFile).toHaveBeenCalledWith(sampleTaxonomy.id, 'csv');
|
||||
});
|
||||
|
||||
it.each(['success', 'error'])('can upload taxonomies from the dialog (%p)', async (expectedResult) => {
|
||||
it.each(['success', 'error'])('can upload taxonomies from the reimport dialog (%p)', async (expectedResult) => {
|
||||
const onClose = jest.fn();
|
||||
const {
|
||||
findByTestId, findByText, getByRole, getAllByTestId, getByTestId, getByText,
|
||||
} = render(<RootWrapper onClose={onClose} />);
|
||||
} = render(<RootWrapper taxonomy={sampleTaxonomy} onClose={onClose} reimport />);
|
||||
|
||||
expect(await findByTestId('export-step')).toBeInTheDocument();
|
||||
|
||||
@@ -223,7 +230,7 @@ describe('<ImportTagsWizard />', () => {
|
||||
if (expectedResult === 'success') {
|
||||
// Toast message shown
|
||||
await waitFor(() => {
|
||||
expect(mockSetToastMessage).toBeCalledWith(`"${taxonomy.name}" updated`);
|
||||
expect(mockSetToastMessage).toBeCalledWith(`"${sampleTaxonomy.name}" updated`);
|
||||
});
|
||||
} else {
|
||||
// Alert message shown
|
||||
@@ -238,4 +245,113 @@ describe('<ImportTagsWizard />', () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['success', 'error'])('can upload new taxonomies from the dialog (%p)', async (expectedResult) => {
|
||||
const onClose = jest.fn();
|
||||
const {
|
||||
findByTestId, getByRole, getByTestId, getByText, queryByTestId,
|
||||
} = render(<RootWrapper taxonomy={null} onClose={onClose} />);
|
||||
|
||||
// Check that there is no export step
|
||||
expect(await queryByTestId('export-step')).not.toBeInTheDocument();
|
||||
// Check that there is no back button in the upload step
|
||||
expect(await queryByTestId('back-button')).not.toBeInTheDocument();
|
||||
|
||||
// Check that we are on the upload step
|
||||
expect(getByTestId('upload-step')).toBeInTheDocument();
|
||||
|
||||
// Continue flow
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Continue' })).toBeInTheDocument());
|
||||
let continueButton = getByRole('button', { name: 'Continue' });
|
||||
expect(continueButton).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(continueButton).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
const makeJson = (filename) => new File(['{}'], filename, { type: 'application/json' });
|
||||
|
||||
// Correct file type
|
||||
fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [makeJson('example1.json')], types: ['Files'] } });
|
||||
expect(await findByTestId('file-info')).toBeInTheDocument();
|
||||
expect(getByText('example1.json')).toBeInTheDocument();
|
||||
|
||||
// Clear file
|
||||
fireEvent.click(getByTestId('clear-file-button'));
|
||||
expect(await findByTestId('dropzone')).toBeInTheDocument();
|
||||
|
||||
// Reselect file
|
||||
fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [makeJson('example1.json')], types: ['Files'] } });
|
||||
expect(await findByTestId('file-info')).toBeInTheDocument();
|
||||
expect(getByText('example1.json')).toBeInTheDocument();
|
||||
|
||||
// Click continue once button enabled
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Continue' })).toBeInTheDocument());
|
||||
continueButton = getByRole('button', { name: 'Continue' });
|
||||
await waitFor(() => {
|
||||
expect(continueButton).not.toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
fireEvent.click(continueButton);
|
||||
|
||||
expect(await findByTestId('populate-step')).toBeInTheDocument();
|
||||
|
||||
// Test back button
|
||||
fireEvent.click(getByTestId('back-button'));
|
||||
expect(getByTestId('upload-step')).toBeInTheDocument();
|
||||
fireEvent.click(getByRole('button', { name: 'Continue' }));
|
||||
expect(await findByTestId('populate-step')).toBeInTheDocument();
|
||||
|
||||
// Check import button is disabled when fields not populated
|
||||
const importButton = getByRole('button', { name: 'Import' });
|
||||
expect(importButton).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
// Populate new taxonomy information
|
||||
const newTaxonomyName = 'New Taxonomy';
|
||||
const taxonomyNameInputEl = screen.getByLabelText('Taxonomy Name');
|
||||
fireEvent.change(taxonomyNameInputEl, {
|
||||
target: { value: newTaxonomyName },
|
||||
});
|
||||
const taxonomyDescInputEl = screen.getByLabelText('Taxonomy Description');
|
||||
fireEvent.change(taxonomyDescInputEl, {
|
||||
target: { value: 'New Taxonomy Description' },
|
||||
});
|
||||
|
||||
// Test back button
|
||||
fireEvent.click(getByTestId('back-button'));
|
||||
expect(getByTestId('upload-step')).toBeInTheDocument();
|
||||
fireEvent.click(getByRole('button', { name: 'Continue' }));
|
||||
|
||||
expect(getByTestId('populate-step')).toBeInTheDocument();
|
||||
|
||||
if (expectedResult === 'success') {
|
||||
axiosMock.onPost(doImportNewTaxonomyUrl).replyOnce(200, {});
|
||||
} else {
|
||||
axiosMock.onPost(doImportNewTaxonomyUrl).replyOnce(400);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByRole('button', { name: 'Import' })).not.toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
act(() => { fireEvent.click(getByRole('button', { name: 'Import' })); });
|
||||
|
||||
if (expectedResult === 'success') {
|
||||
// Toast message shown
|
||||
await waitFor(() => {
|
||||
expect(mockSetToastMessage).toBeCalledWith(`"${newTaxonomyName}" imported`);
|
||||
});
|
||||
} else {
|
||||
// Alert message shown
|
||||
await waitFor(() => {
|
||||
expect(mockSetAlertProps).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
variant: 'danger',
|
||||
title: 'Import error',
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// @ts-check
|
||||
export { importTaxonomy } from './utils';
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as ImportTagsWizard } from './ImportTagsWizard';
|
||||
|
||||
@@ -56,10 +56,37 @@ const messages = defineMessages({
|
||||
},
|
||||
importWizardStepUploadBody: {
|
||||
id: 'course-authoring.import-tags.wizard.step-upload.body',
|
||||
defaultMessage: 'You can upload a CSV or JSON file to create a new taxonomy. 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. '
|
||||
+ 'For an example of the required format, download the {csvTemplateUrl} or {jsonTemplateUrl}.'
|
||||
+ '{br}Once the file is ready to be imported, drag and drop it into the box below, or click to upload.',
|
||||
},
|
||||
importWizardStepReuploadBody: {
|
||||
id: 'course-authoring.import-tags.wizard.step-reupload.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.',
|
||||
},
|
||||
csvTemplateTitle: {
|
||||
id: 'course-authoring.import-tags.wizard.step-upload.csv-template',
|
||||
defaultMessage: 'CSV template',
|
||||
},
|
||||
jsonTemplateTitle: {
|
||||
id: 'course-authoring.import-tags.wizard.step-upload.json-template',
|
||||
defaultMessage: 'JSON template',
|
||||
},
|
||||
importWizardStepPopulateTitle: {
|
||||
id: 'course-authoring.import-tags.wizard.step-populate.title',
|
||||
defaultMessage: 'Populate Taxonomy Information',
|
||||
},
|
||||
importWizardStepPopulateTaxonomyName: {
|
||||
id: 'course-authoring.import-tags.wizard.step-populate.name',
|
||||
defaultMessage: 'Taxonomy Name',
|
||||
},
|
||||
importWizardStepPopulateTaxonomyDesc: {
|
||||
id: 'course-authoring.import-tags.wizard.step-populate.desc',
|
||||
defaultMessage: 'Taxonomy Description',
|
||||
},
|
||||
importWizardStepPlanTitle: {
|
||||
id: 'course-authoring.import-tags.wizard.step-plan.title',
|
||||
defaultMessage: 'Differences between files',
|
||||
@@ -116,6 +143,10 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.import-tags.error',
|
||||
defaultMessage: 'Import failed - see details in the browser console',
|
||||
},
|
||||
importNewTaxonomyToast: {
|
||||
id: 'course-authoring.import-tags.new.toast.success',
|
||||
defaultMessage: '"{name}" imported',
|
||||
},
|
||||
importTaxonomyToast: {
|
||||
id: 'course-authoring.import-tags.toast.success',
|
||||
defaultMessage: '"{name}" updated',
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
// @ts-check
|
||||
import messages from './messages';
|
||||
|
||||
/*
|
||||
* This function get a file from the user. It does this by creating a
|
||||
* file input element, and then clicking it. This allows us to get a file
|
||||
* from the user without using a form. The file input element is created
|
||||
* and appended to the DOM, then clicked. When the user selects a file,
|
||||
* the change event is fired, and the file is resolved.
|
||||
* The file input element is then removed from the DOM.
|
||||
*/
|
||||
/* istanbul ignore next */
|
||||
const selectFile = async () => new Promise((resolve) => {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = '.json,.csv';
|
||||
fileInput.style.display = 'none';
|
||||
fileInput.addEventListener('change', (/** @type { Event & { target: HTMLInputElement} } */ event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
resolve(null);
|
||||
}
|
||||
resolve(file);
|
||||
document.body.removeChild(fileInput);
|
||||
}, false);
|
||||
|
||||
fileInput.addEventListener('cancel', () => {
|
||||
resolve(null);
|
||||
document.body.removeChild(fileInput);
|
||||
}, false);
|
||||
|
||||
document.body.appendChild(fileInput);
|
||||
|
||||
// Calling click() directly was not working as expected, so we use setTimeout
|
||||
// to ensure the file input is added to the DOM before clicking it.
|
||||
setTimeout(() => fileInput.click(), 0);
|
||||
});
|
||||
|
||||
/* istanbul ignore next */
|
||||
/**
|
||||
* @param {*} intl The react-intl object returned by the useIntl() hook
|
||||
* @param {ReturnType<typeof import('../data/apiHooks').useImportNewTaxonomy>} importMutation The import mutation
|
||||
* returned by the useImportNewTaxonomy() hook.
|
||||
* @param {() => void} showImportInProgressAlert Function to show `In progress` alert.
|
||||
* @param {() => void} closeImportInProgressAlert Function to close `In progress` alert.
|
||||
*/
|
||||
export const importTaxonomy = async ( // eslint-disable-line import/prefer-default-export
|
||||
intl,
|
||||
importMutation,
|
||||
showImportInProgressAlert,
|
||||
closeImportInProgressAlert,
|
||||
) => {
|
||||
/*
|
||||
* This function is a temporary "Barebones" implementation of the import
|
||||
* functionality with `prompt` 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/116
|
||||
*/
|
||||
/* eslint-disable no-alert */
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const getTaxonomyName = () => {
|
||||
let taxonomyName = null;
|
||||
while (!taxonomyName) {
|
||||
taxonomyName = prompt(intl.formatMessage(messages.promptTaxonomyName));
|
||||
|
||||
if (taxonomyName == null) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!taxonomyName) {
|
||||
alert(intl.formatMessage(messages.promptTaxonomyNameRequired));
|
||||
}
|
||||
}
|
||||
return taxonomyName;
|
||||
};
|
||||
|
||||
const getTaxonomyDescription = () => prompt(intl.formatMessage(messages.promptTaxonomyDescription));
|
||||
|
||||
const file = await selectFile();
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = getTaxonomyName();
|
||||
if (name == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const description = getTaxonomyDescription();
|
||||
if (description == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
showImportInProgressAlert();
|
||||
|
||||
importMutation.mutateAsync({
|
||||
name,
|
||||
description,
|
||||
file,
|
||||
}).then(() => {
|
||||
closeImportInProgressAlert();
|
||||
alert(intl.formatMessage(messages.importTaxonomySuccess));
|
||||
}).catch((error) => {
|
||||
closeImportInProgressAlert();
|
||||
alert(intl.formatMessage(messages.importTaxonomyError));
|
||||
console.error(error.response);
|
||||
});
|
||||
};
|
||||
@@ -117,6 +117,7 @@ const TaxonomyMenu = ({
|
||||
taxonomy={taxonomy}
|
||||
isOpen={isImportModalOpen}
|
||||
onClose={importModalClose}
|
||||
reimport
|
||||
/>
|
||||
)}
|
||||
{isManageOrgsModalOpen && (
|
||||
|
||||
Reference in New Issue
Block a user