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:
Yusuf Musleh
2024-05-24 16:49:26 +03:00
committed by GitHub
parent c3df0b0692
commit d0b3328f26
9 changed files with 352 additions and 175 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,3 @@
// @ts-check
export { importTaxonomy } from './utils';
// eslint-disable-next-line import/prefer-default-export
export { default as ImportTagsWizard } from './ImportTagsWizard';

View File

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

View File

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

View File

@@ -117,6 +117,7 @@ const TaxonomyMenu = ({
taxonomy={taxonomy}
isOpen={isImportModalOpen}
onClose={importModalClose}
reimport
/>
)}
{isManageOrgsModalOpen && (