diff --git a/src/generic/loading-button/LoadingButton.test.jsx b/src/generic/loading-button/LoadingButton.test.jsx new file mode 100644 index 0000000000..d3d54f6a89 --- /dev/null +++ b/src/generic/loading-button/LoadingButton.test.jsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import LoadingButton from '.'; + +const buttonTitle = 'Button Title'; + +const RootWrapper = (onClick) => ( + + {buttonTitle} + +); + +describe('', () => { + it('renders the title and doesnt the spinner initially', () => { + const { getByText, getByTestId } = render(RootWrapper()); + const titleElement = getByText(buttonTitle); + expect(titleElement).toBeInTheDocument(); + expect(() => getByTestId('button-loading-spinner')).toThrow('Unable to find an element'); + }); + + it('doesnt render the spinner initially without onClick function', () => { + const { getByText, getByTestId } = render(RootWrapper()); + const titleElement = getByText(buttonTitle); + expect(titleElement).toBeInTheDocument(); + expect(() => getByTestId('button-loading-spinner')).toThrow('Unable to find an element'); + }); + + it('renders the spinner correctly', () => { + const longFunction = () => new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + const { getByRole, getByText, getByTestId } = render(RootWrapper(longFunction)); + const buttonElement = getByRole('button'); + buttonElement.click(); + const spinnerElement = getByTestId('button-loading-spinner'); + expect(spinnerElement).toBeInTheDocument(); + const titleElement = getByText(buttonTitle); + expect(titleElement).toBeInTheDocument(); + expect(buttonElement).toBeDisabled(); + setTimeout(() => { + expect(buttonElement).toBeEnabled(); + expect(spinnerElement).not.toBeInTheDocument(); + }, 2000); + }); +}); diff --git a/src/generic/loading-button/index.jsx b/src/generic/loading-button/index.jsx new file mode 100644 index 0000000000..b092b5c792 --- /dev/null +++ b/src/generic/loading-button/index.jsx @@ -0,0 +1,52 @@ +import { useState } from 'react'; + +import { + Button, + Spinner, + Stack, +} from '@edx/paragon'; + +const LoadingButton = ({ + onClick, + children, + disabled, + ...props +}) => { + const [isLoading, setIsLoading] = useState(false); + + const loadingOnClick = async (e) => { + if (onClick === undefined) { + return; + } + + setIsLoading(true); + try { + await onClick(e); + } finally { + setIsLoading(false); + } + }; + + return ( + + + {children} + {isLoading && } + + + ); +}; + +LoadingButton.propTypes = { + ...Button.propTypes, +}; + +LoadingButton.defaultProps = { + ...Button.defaultProps, +}; + +export default LoadingButton; diff --git a/src/taxonomy/import-tags/ImportTagsWizard.jsx b/src/taxonomy/import-tags/ImportTagsWizard.jsx new file mode 100644 index 0000000000..9fe49c4e8d --- /dev/null +++ b/src/taxonomy/import-tags/ImportTagsWizard.jsx @@ -0,0 +1,300 @@ +// ts-check +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Button, + Container, + Dropzone, + Icon, + ModalDialog, + Stack, + Stepper, +} from '@edx/paragon'; +import { Download, Warning } from '@edx/paragon/icons'; +import PropTypes from 'prop-types'; +import { useState } from 'react'; + +import LoadingButton from '../../generic/loading-button'; +import { getTaxonomyExportFile } from '../data/api'; +import { planImportTags, useImportTags } from './data/api'; +import messages from './messages'; + +const linebreak = <> >; + +const TaxonomyProp = PropTypes.shape({ + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, +}); + +const ExportStep = ({ taxonomy }) => { + const intl = useIntl(); + + return ( + + + {intl.formatMessage(messages.importWizardStepExportBody, { br: linebreak })} + + getTaxonomyExportFile(taxonomy.id, 'csv')} + > + {intl.formatMessage(messages.importWizardStepExportCSVButton)} + + getTaxonomyExportFile(taxonomy.id, 'json')} + > + {intl.formatMessage(messages.importWizardStepExportJSONButton)} + + + + + ); +}; + +ExportStep.propTypes = { + taxonomy: TaxonomyProp.isRequired, +}; + +const UploadStep = ({ setFile, importPlanError, setImportPlanError }) => { + const intl = useIntl(); + + const [filePreview, setFilePreview] = useState(null); + + const handleFileLoad = ({ fileData }) => { + setFile(fileData.get('file')); + fileData.get('file').text().then((text) => { + setFilePreview(text); + setImportPlanError(null); + }); + }; + + return ( + + + {intl.formatMessage(messages.importWizardStepUploadBody, { br: linebreak })} + + {filePreview} + + ) + } + /> + {importPlanError && {importPlanError}} + + + ); +}; + +UploadStep.propTypes = { + taxonomy: TaxonomyProp.isRequired, + setFile: PropTypes.func.isRequired, + importPlanError: PropTypes.string, + setImportPlanError: PropTypes.func.isRequired, +}; + +UploadStep.defaultProps = { + importPlanError: null, +}; + +const PlanStep = ({ importPlan }) => { + const intl = useIntl(); + + return ( + + + + + + {intl.formatMessage(messages.importWizardStepPlanAlert, { changeCount: importPlan?.length })} + + + {intl.formatMessage(messages.importWizardStepPlanBody)} + + {importPlan && importPlan.map((line) => {line})} + + + + ); +}; + +PlanStep.propTypes = { + importPlan: PropTypes.arrayOf(PropTypes.string).isRequired, +}; + +const ConfirmStep = ({ importPlan }) => { + const intl = useIntl(); + + return ( + + {intl.formatMessage( + messages.importWizardStepConfirmBody, + { br: linebreak, changeCount: importPlan?.length }, + )} + + ); +}; + +ConfirmStep.propTypes = { + importPlan: PropTypes.arrayOf(PropTypes.string).isRequired, +}; + +const ImportTagsWizard = ({ + taxonomy, + isOpen, + close, +}) => { + const intl = useIntl(); + + const steps = ['export', 'upload', 'plan', 'confirm']; + const [currentStep, setCurrentStep] = useState(steps[0]); + + const [file, setFile] = useState(null); + + const [importPlan, setImportPlan] = useState(null); + const [importPlanError, setImportPlanError] = useState(null); + + const importTagsMutation = useImportTags(); + + const generatePlan = async () => { + try { + const plan = await planImportTags(taxonomy.id, file); + const planArray = plan + .split('\n') + .toSpliced(0, 2) // Removes the header in the first two lines + .toSpliced(-1) // Removes the empty last line + .filter((line) => !(line.includes('No changes'))) // Removes the "No changes" lines + .map((line) => line.split(':')[1].trim()); // Get only the action message + setImportPlan(planArray); + setCurrentStep('plan'); + } catch (error) { + setImportPlanError(error.message); + } + }; + + const confirmImportTags = async () => { + try { + await importTagsMutation.mutateAsync({ + taxonomyId: taxonomy.id, + file, + }); + close(); + } catch (error) { + setImportPlanError(error.message); + } + }; + + const stepTitles = { + export: intl.formatMessage(messages.importWizardStepExportTitle, { name: taxonomy.name }), + upload: intl.formatMessage(messages.importWizardStepUploadTitle), + plan: intl.formatMessage(messages.importWizardStepPlanTitle), + confirm: ( + + + {intl.formatMessage(messages.importWizardStepConfirmTitle, { changeCount: importPlan?.length })} + + ), + }; + + return ( + e.stopPropagation() /* This prevents calling onClick handler from the parent */} + > + + + + {stepTitles[currentStep]} + + + + + + + + + + + + + + + + + + {intl.formatMessage(messages.importWizardButtonCancel)} + + setCurrentStep('upload')}> + {intl.formatMessage(messages.importWizardButtonNext)} + + + + + setCurrentStep('export')}> + {intl.formatMessage(messages.importWizardButtonPrevious)} + + + + {intl.formatMessage(messages.importWizardButtonCancel)} + + + {intl.formatMessage(messages.importWizardButtonImport)} + + + + + setCurrentStep('upload')}> + Previous + + + + {intl.formatMessage(messages.importWizardButtonCancel)} + + setCurrentStep('confirm')}>Apply + + + + setCurrentStep('plan')}> + Previous + + + + {intl.formatMessage(messages.importWizardButtonCancel)} + + + {intl.formatMessage(messages.importWizardStepConfirmButton)} + + + + + + + + ); +}; + +ImportTagsWizard.propTypes = { + taxonomy: TaxonomyProp.isRequired, + isOpen: PropTypes.bool.isRequired, + close: PropTypes.func.isRequired, +}; + +export default ImportTagsWizard; diff --git a/src/taxonomy/import-tags/data/api.js b/src/taxonomy/import-tags/data/api.js index befb2e977d..baf87bac03 100644 --- a/src/taxonomy/import-tags/data/api.js +++ b/src/taxonomy/import-tags/data/api.js @@ -1,6 +1,7 @@ // @ts-check import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { useQueryClient, useMutation } from '@tanstack/react-query'; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; @@ -18,6 +19,15 @@ 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 @@ -40,19 +50,62 @@ 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 + * @returns {import("@tanstack/react-query").UseMutationResult} + */ +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); + + await getAuthenticatedHttpClient().put( + getTagsImportApiUrl(taxonomyId), + formData, + ); + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: ['tagList', variables.taxonomyId], + }); + }, + }); +}; + +/** + * Plan import tags to an existing taxonomy, overwriting existing tags * @param {number} taxonomyId * @param {File} file * @returns {Promise} */ -export async function importTags(taxonomyId, file) { +export async function planImportTags(taxonomyId, file) { const formData = new FormData(); formData.append('file', file); - const { data } = await getAuthenticatedHttpClient().put( - getTagsImportApiUrl(taxonomyId), - formData, - ); + try { + const { data } = await getAuthenticatedHttpClient().put( + getTagsPlanImportApiUrl(taxonomyId), + formData, + ); - return camelCaseObject(data); + return camelCaseObject(data.plan); + } catch (err) { + // @ts-ignore + if (err.response?.data?.error) { + // @ts-ignore + throw new Error(err.response.data.error); + } + throw err; + } } diff --git a/src/taxonomy/import-tags/data/api.test.js b/src/taxonomy/import-tags/data/api.test.js index 0da9f84eae..4fee3d8871 100644 --- a/src/taxonomy/import-tags/data/api.test.js +++ b/src/taxonomy/import-tags/data/api.test.js @@ -1,6 +1,7 @@ -import MockAdapter from 'axios-mock-adapter'; import { initializeMockApp } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { useMutation } from '@tanstack/react-query'; +import MockAdapter from 'axios-mock-adapter'; import { tagImportMock, taxonomyImportMock } from '../__mocks__'; @@ -8,7 +9,7 @@ import { getTaxonomyImportNewApiUrl, getTagsImportApiUrl, importNewTaxonomy, - importTags, + useImportTags, } from './api'; let axiosMock; @@ -40,9 +41,9 @@ describe('import taxonomy api calls', () => { it('should call import tags', async () => { axiosMock.onPut(getTagsImportApiUrl(1)).reply(200, tagImportMock); - const result = await importTags(1); + const mutation = useImportTags(); + mutation.mutate(1); expect(axiosMock.history.put[0].url).toEqual(getTagsImportApiUrl(1)); - expect(result).toEqual(tagImportMock); }); }); diff --git a/src/taxonomy/import-tags/data/utils.js b/src/taxonomy/import-tags/data/utils.js index e46e76941e..f25a270c3f 100644 --- a/src/taxonomy/import-tags/data/utils.js +++ b/src/taxonomy/import-tags/data/utils.js @@ -1,6 +1,6 @@ // ts-check import messages from '../messages'; -import { importNewTaxonomy, importTags } from './api'; +import { importNewTaxonomy } from './api'; /* * This function get a file from the user. It does this by creating a @@ -36,6 +36,7 @@ const selectFile = async () => new Promise((resolve) => { setTimeout(() => fileInput.click(), 0); }); +// eslint-disable-next-line import/prefer-default-export export const importTaxonomy = async (intl) => { /* * This function is a temporary "Barebones" implementation of the import @@ -89,32 +90,3 @@ export const importTaxonomy = async (intl) => { console.error(error.response); }); }; - -export const importTaxonomyTags = async (taxonomyId, intl) => { - /* - * This function is a temporary "Barebones" implementation of the import - * functionality with `confirm` and `alert`. It is intended to be replaced - * with a component that shows a `ModalDialog` in the future. - * See: https://github.com/openedx/modular-learning/issues/126 - */ - /* eslint-disable no-alert */ - /* eslint-disable no-console */ - const file = await selectFile(); - - if (!file) { - return; - } - - if (!window.confirm(intl.formatMessage(messages.confirmImportTags))) { - return; - } - - importTags(taxonomyId, file) - .then(() => { - alert(intl.formatMessage(messages.importTaxonomySuccess)); - }) - .catch((error) => { - alert(intl.formatMessage(messages.importTaxonomyError)); - console.error(error.response); - }); -}; diff --git a/src/taxonomy/import-tags/data/utils.test.js b/src/taxonomy/import-tags/data/utils.test.js index ddcc029410..a9f528be41 100644 --- a/src/taxonomy/import-tags/data/utils.test.js +++ b/src/taxonomy/import-tags/data/utils.test.js @@ -1,5 +1,5 @@ -import { importTaxonomy, importTaxonomyTags } from './utils'; -import { importNewTaxonomy, importTags } from './api'; +import { importTaxonomy } from './utils'; +import { importNewTaxonomy } from './api'; const mockAddEventListener = jest.fn(); @@ -198,104 +198,4 @@ describe('import new taxonomy functions', () => { return promise; }); }); - - describe('import tags', () => { - it('should call the api and show success alert', async () => { - jest.spyOn(window, 'confirm').mockReturnValueOnce(true); - jest.spyOn(window, 'alert').mockImplementation(() => {}); - - const promise = importTaxonomyTags(1, intl).then(() => { - expect(importTags).toHaveBeenCalledWith(1, 'mockFile'); - expect(window.alert).toHaveBeenCalledWith('Taxonomy imported successfully'); - }); - - // Capture the onChange handler from the file input element - const onChange = mockAddEventListener.mock.calls[0][1]; - const mockTarget = { - target: { - files: [ - 'mockFile', - ], - }, - }; - - onChange(mockTarget); - - return promise; - }); - - it('should abort the call to the api without file', async () => { - const promise = importTaxonomyTags(1, intl).then(() => { - expect(importTags).not.toHaveBeenCalled(); - }); - - // Capture the onChange handler from the file input element - const onChange = mockAddEventListener.mock.calls[0][1]; - const mockTarget = { - target: { - files: [null], - }, - }; - - onChange(mockTarget); - return promise; - }); - - it('should abort the call to the api if file closed', async () => { - const promise = importTaxonomyTags(1, intl).then(() => { - expect(importTags).not.toHaveBeenCalled(); - }); - - // Capture the onCancel handler from the file input element - const onCancel = mockAddEventListener.mock.calls[1][1]; - - onCancel(); - return promise; - }); - - it('should abort the call to the api when cancel the confirm dialog', async () => { - jest.spyOn(window, 'confirm').mockReturnValueOnce(null); - - const promise = importTaxonomyTags(1, intl).then(() => { - expect(importTags).not.toHaveBeenCalled(); - }); - - // Capture the onChange handler from the file input element - const onChange = mockAddEventListener.mock.calls[0][1]; - const mockTarget = { - target: { - files: [ - 'mockFile', - ], - }, - }; - - onChange(mockTarget); - - return promise; - }); - - it('should call the api and return error alert', async () => { - jest.spyOn(window, 'confirm').mockReturnValueOnce(true); - importTags.mockRejectedValue(new Error('test error')); - - const promise = importTaxonomyTags(1, intl).then(() => { - expect(importTags).toHaveBeenCalledWith(1, 'mockFile'); - }); - - // Capture the onChange handler from the file input element - const onChange = mockAddEventListener.mock.calls[0][1]; - const mockTarget = { - target: { - files: [ - 'mockFile', - ], - }, - }; - - onChange(mockTarget); - - return promise; - }); - }); }); diff --git a/src/taxonomy/import-tags/index.js b/src/taxonomy/import-tags/index.js index 40100badeb..f3aa484c0a 100644 --- a/src/taxonomy/import-tags/index.js +++ b/src/taxonomy/import-tags/index.js @@ -1,6 +1,7 @@ -import { importTaxonomyTags, importTaxonomy } from './data/utils'; +import { importTaxonomy } from './data/utils'; +import ImportTagsWizard from './ImportTagsWizard'; export { - importTaxonomyTags, importTaxonomy, + ImportTagsWizard, }; diff --git a/src/taxonomy/import-tags/messages.js b/src/taxonomy/import-tags/messages.js index eaa6780d9f..9f6b0b86e2 100644 --- a/src/taxonomy/import-tags/messages.js +++ b/src/taxonomy/import-tags/messages.js @@ -2,6 +2,79 @@ 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', + }, + 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 download 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', + }, + 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', + }, + importWizardStepPlanAlert: { + id: 'course-authoring.import-tags.wizard.step-plan.alert', + 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.', + }, + importWizardStepPlanBody: { + id: 'course-authoring.import-tags.wizard.step-plan.body', + defaultMessage: 'Importing this file will cause the following updates:', + }, + 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} updates to tags. 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?', + }, + importWizardStepConfirmButton: { + id: 'course-authoring.import-tags.wizard.step-confirm.button', + defaultMessage: 'Yes, import file', + }, promptTaxonomyName: { id: 'course-authoring.import-tags.prompt.taxonomy-name', defaultMessage: 'Enter a name for the new taxonomy', diff --git a/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx b/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx index 44e1daf3fe..43ee28f57f 100644 --- a/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx +++ b/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx @@ -12,7 +12,7 @@ import PropTypes from 'prop-types'; import _ from 'lodash'; import ExportModal from '../export-modal'; -import { importTaxonomyTags } from '../import-tags'; +import { ImportTagsWizard } from '../import-tags'; import messages from './messages'; const TaxonomyMenu = ({ @@ -21,12 +21,13 @@ const TaxonomyMenu = ({ const intl = useIntl(); const [isExportModalOpen, exportModalOpen, exportModalClose] = useToggle(false); + const [isImportModalOpen, importModalOpen, importModalClose] = useToggle(false); const getTaxonomyMenuItems = () => { 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, }, @@ -44,13 +45,24 @@ const TaxonomyMenu = ({ const menuItems = getTaxonomyMenuItems(); - const renderModals = () => isExportModalOpen && ( - + const renderModals = () => ( + <> + {isExportModalOpen && ( + + )} + {isImportModalOpen && ( + + )} + > ); return ( diff --git a/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx b/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx index 142049f2ac..caef75eb6d 100644 --- a/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx +++ b/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx @@ -6,7 +6,6 @@ import PropTypes from 'prop-types'; import initializeStore from '../../store'; import { getTaxonomyExportFile } from '../data/api'; -import { importTaxonomyTags } from '../import-tags'; import { TaxonomyMenu } from '.'; let store; @@ -149,7 +148,8 @@ describe('', async () => { fireEvent.click(getByTestId('taxonomy-menu-button')); fireEvent.click(getByTestId('taxonomy-menu-import')); - expect(importTaxonomyTags).toHaveBeenCalled(); + // Modal opened + expect(getByTestId('import-tags-wizard')).toBeInTheDocument(); }); test('should export a taxonomy', () => {
{intl.formatMessage(messages.importWizardStepExportBody, { br: linebreak })}
{intl.formatMessage(messages.importWizardStepUploadBody, { br: linebreak })}