From d8bc6c1fdcbd40ab633971df4c26b0612f2c5fa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Tue, 21 Nov 2023 17:47:46 -0300 Subject: [PATCH] feat: import tags to existing taxonomy --- src/taxonomy/import-tags/__mocks__/index.js | 2 +- .../import-tags/__mocks__/tagImportMock.js | 4 + src/taxonomy/import-tags/data/api.js | 31 ++++++- src/taxonomy/import-tags/data/api.test.js | 18 +++- src/taxonomy/import-tags/data/utils.js | 93 +++++++++++++------ src/taxonomy/import-tags/index.js | 5 +- src/taxonomy/import-tags/messages.js | 7 ++ .../taxonomy-card/TaxonomyCardMenu.jsx | 8 +- src/taxonomy/taxonomy-card/index.jsx | 4 +- src/taxonomy/taxonomy-card/messages.js | 4 + 10 files changed, 138 insertions(+), 38 deletions(-) create mode 100644 src/taxonomy/import-tags/__mocks__/tagImportMock.js diff --git a/src/taxonomy/import-tags/__mocks__/index.js b/src/taxonomy/import-tags/__mocks__/index.js index 84c5b352ac..ba0b48ccb9 100644 --- a/src/taxonomy/import-tags/__mocks__/index.js +++ b/src/taxonomy/import-tags/__mocks__/index.js @@ -1,2 +1,2 @@ -// eslint-disable-next-line import/prefer-default-export export { default as taxonomyImportMock } from './taxonomyImportMock'; +export { default as tagImportMock } from './tagImportMock'; diff --git a/src/taxonomy/import-tags/__mocks__/tagImportMock.js b/src/taxonomy/import-tags/__mocks__/tagImportMock.js new file mode 100644 index 0000000000..9db45b4a5e --- /dev/null +++ b/src/taxonomy/import-tags/__mocks__/tagImportMock.js @@ -0,0 +1,4 @@ +export default { + name: 'Taxonomy name', + description: 'Taxonomy description', +}; diff --git a/src/taxonomy/import-tags/data/api.js b/src/taxonomy/import-tags/data/api.js index 53cb1cb0f3..befb2e977d 100644 --- a/src/taxonomy/import-tags/data/api.js +++ b/src/taxonomy/import-tags/data/api.js @@ -4,11 +4,20 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; -export const getTaxonomyImportApiUrl = () => new URL( +export const getTaxonomyImportNewApiUrl = () => new URL( 'api/content_tagging/v1/taxonomies/import/', getApiBaseUrl(), ).href; +/** + * @param {number} taxonomyId + * @returns {string} + */ +export const getTagsImportApiUrl = (taxonomyId) => new URL( + `api/content_tagging/v1/taxonomies/${taxonomyId}/tags/import/`, + getApiBaseUrl(), +).href; + /** * Import a new taxonomy * @param {string} taxonomyName @@ -23,7 +32,25 @@ export async function importNewTaxonomy(taxonomyName, taxonomyDescription, file) formData.append('file', file); const { data } = await getAuthenticatedHttpClient().post( - getTaxonomyImportApiUrl(), + getTaxonomyImportNewApiUrl(), + formData, + ); + + return camelCaseObject(data); +} + +/** + * Import tags to an existing taxonomy, overwriting existing tags + * @param {number} taxonomyId + * @param {File} file + * @returns {Promise} + */ +export async function importTags(taxonomyId, file) { + const formData = new FormData(); + formData.append('file', file); + + const { data } = await getAuthenticatedHttpClient().put( + getTagsImportApiUrl(taxonomyId), formData, ); diff --git a/src/taxonomy/import-tags/data/api.test.js b/src/taxonomy/import-tags/data/api.test.js index c40053cc53..e2022a2df6 100644 --- a/src/taxonomy/import-tags/data/api.test.js +++ b/src/taxonomy/import-tags/data/api.test.js @@ -2,11 +2,13 @@ import MockAdapter from 'axios-mock-adapter'; import { initializeMockApp } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { taxonomyImportMock } from '../__mocks__'; +import { tagImportMock, taxonomyImportMock } from '../__mocks__'; import { - getTaxonomyImportApiUrl, + getTaxonomyImportNewApiUrl, + getTagsImportApiUrl, importNewTaxonomy, + importTags, } from './api'; let axiosMock; @@ -28,11 +30,19 @@ describe('import taxonomy api calls', () => { jest.clearAllMocks(); }); - it('should call import taxonomy', async () => { - axiosMock.onPost(getTaxonomyImportApiUrl()).reply(201, taxonomyImportMock); + 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(getTaxonomyImportApiUrl()); expect(result).toEqual(taxonomyImportMock); }); + + it('should call import tags', async () => { + axiosMock.onPost(getTagsImportApiUrl(1)).reply(200, tagImportMock); + const result = await importTags(1); + + expect(axiosMock.history.post[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 027ff5065b..38e17fa48a 100644 --- a/src/taxonomy/import-tags/data/utils.js +++ b/src/taxonomy/import-tags/data/utils.js @@ -1,7 +1,41 @@ +// ts-check import messages from '../messages'; -import { importNewTaxonomy } from './api'; +import { importNewTaxonomy, importTags } from './api'; + +/* + * 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. +*/ +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', (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); +}); -// eslint-disable-next-line import/prefer-default-export export const importTaxonomy = async (intl) => { /* * This function is a temporary "Barebones" implementation of the import @@ -12,31 +46,6 @@ export const importTaxonomy = async (intl) => { /* eslint-disable no-alert */ /* eslint-disable no-console */ - const selectFile = async () => new Promise((resolve) => { - /* - * 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. - */ - const fileInput = document.createElement('input'); - fileInput.type = 'file'; - fileInput.accept = '.json,.csv'; - fileInput.addEventListener('change', (event) => { - const file = event.target.files[0]; - if (!file) { - resolve(null); - } - resolve(file); - document.body.removeChild(fileInput); - }); - - document.body.appendChild(fileInput); - fileInput.click(); - }); - const getTaxonomyName = () => { let taxonomyName = null; while (!taxonomyName) { @@ -80,3 +89,33 @@ 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 */ + console.log(intl); + 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/index.js b/src/taxonomy/import-tags/index.js index bfd7f80725..40100badeb 100644 --- a/src/taxonomy/import-tags/index.js +++ b/src/taxonomy/import-tags/index.js @@ -1,5 +1,6 @@ -import { importTaxonomy } from './data/utils'; +import { importTaxonomyTags, importTaxonomy } from './data/utils'; export { - importTaxonomy, // eslint-disable-line import/prefer-default-export + importTaxonomyTags, + importTaxonomy, }; diff --git a/src/taxonomy/import-tags/messages.js b/src/taxonomy/import-tags/messages.js index f9a1ff273b..eaa6780d9f 100644 --- a/src/taxonomy/import-tags/messages.js +++ b/src/taxonomy/import-tags/messages.js @@ -1,3 +1,4 @@ +// ts-check import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ @@ -21,6 +22,12 @@ 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?', + }, }); export default messages; diff --git a/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx b/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx index 1b27c8ee61..6fef8b672e 100644 --- a/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx +++ b/src/taxonomy/taxonomy-card/TaxonomyCardMenu.jsx @@ -31,7 +31,13 @@ const TaxonomyCardMenu = ({ data-testid={`taxonomy-card-menu-button-${id}`} /> - {/* Add more menu items here */} + onClickItem(e, 'import')} + > + {intl.formatMessage(messages.taxonomyCardImportMenu)} + { const intl = useIntl(); const [isExportModalOpen, setIsExportModalOpen] = useState(false); - // Add here more menu item actions const menuItemActions = { + import: () => importTaxonomyTags(id, intl).then(() => console.log('resolved')), export: () => setIsExportModalOpen(true), }; diff --git a/src/taxonomy/taxonomy-card/messages.js b/src/taxonomy/taxonomy-card/messages.js index 6886c2f99c..def583a96d 100644 --- a/src/taxonomy/taxonomy-card/messages.js +++ b/src/taxonomy/taxonomy-card/messages.js @@ -17,6 +17,10 @@ const messages = defineMessages({ id: 'course-authoring.taxonomy-list.orgs-count.label', defaultMessage: 'Assigned to {orgsCount} orgs', }, + taxonomyCardImportMenu: { + id: 'course-authoring.taxonomy-list.menu.import.label', + defaultMessage: 'Re-import', + }, taxonomyCardExportMenu: { id: 'course-authoring.taxonomy-list.menu.export.label', defaultMessage: 'Export',