Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: import tags to existing taxonomy [FC-0036] #708

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 24 additions & 7 deletions src/taxonomy/TaxonomyListPage.jsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,39 @@
import React from 'react';
import {
Button,
CardView,
Container,
DataTable,
Spinner,
} from '@edx/paragon';
import {
Add,
} from '@edx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import SubHeader from '../generic/sub-header/SubHeader';
import { importTaxonomy } from './import-tags';
import messages from './messages';
import TaxonomyCard from './taxonomy-card';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './data/apiHooks';

const TaxonomyListHeaderButtons = () => {
const intl = useIntl();
return (
<>
<Button variant="outline-primary" disabled>
{intl.formatMessage(messages.downloadTemplateButtonLabel)}
</Button>
<Button
iconBefore={Add}
onClick={() => importTaxonomy(intl)}
data-testid="taxonomy-import-button"
>
{intl.formatMessage(messages.importButtonLabel)}
</Button>
</>
);
};

const TaxonomyListPage = () => {
const intl = useIntl();
const useTaxonomyListData = () => {
Expand All @@ -21,12 +44,6 @@ const TaxonomyListPage = () => {

const { taxonomyListData, isLoaded } = useTaxonomyListData();

const getHeaderButtons = () => (
// Download template and import buttons.
// TODO Add functionality to this buttons.
undefined
);

const getOrgSelect = () => (
// Organization select component
// TODO Add functionality to this component
Expand All @@ -40,7 +57,7 @@ const TaxonomyListPage = () => {
<SubHeader
title={intl.formatMessage(messages.headerTitle)}
titleActions={getOrgSelect()}
headerActions={getHeaderButtons()}
headerActions={<TaxonomyListHeaderButtons />}
hideBorder
/>
</Container>
Expand Down
23 changes: 23 additions & 0 deletions src/taxonomy/TaxonomyListPage.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import initializeStore from '../store';

import TaxonomyListPage from './TaxonomyListPage';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './data/apiHooks';
import { importTaxonomy } from './import-tags';

let store;

Expand All @@ -16,6 +17,10 @@ jest.mock('./data/apiHooks', () => ({
useIsTaxonomyListDataLoaded: jest.fn(),
}));

jest.mock('./import-tags', () => ({
importTaxonomy: jest.fn(),
}));

const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
Expand Down Expand Up @@ -65,4 +70,22 @@ describe('<TaxonomyListPage />', async () => {
expect(getByTestId('taxonomy-card-1')).toBeInTheDocument();
});
});

it('calls the import taxonomy action when the import button is clicked', async () => {
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useTaxonomyListDataResponse.mockReturnValue({
results: [{
id: 1,
name: 'Taxonomy',
description: 'This is a description',
}],
});
await act(async () => {
const { getByTestId } = render(<RootWrapper />);
const importButton = getByTestId('taxonomy-import-button');
expect(importButton).toBeInTheDocument();
importButton.click();
expect(importTaxonomy).toHaveBeenCalled();
});
});
});
2 changes: 2 additions & 0 deletions src/taxonomy/import-tags/__mocks__/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as taxonomyImportMock } from './taxonomyImportMock';
export { default as tagImportMock } from './tagImportMock';
4 changes: 4 additions & 0 deletions src/taxonomy/import-tags/__mocks__/tagImportMock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export default {
name: 'Taxonomy name',
description: 'Taxonomy description',
};
4 changes: 4 additions & 0 deletions src/taxonomy/import-tags/__mocks__/taxonomyImportMock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export default {
name: 'Taxonomy name',
description: 'Taxonomy description',
};
58 changes: 58 additions & 0 deletions src/taxonomy/import-tags/data/api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// @ts-check
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';

const getApiBaseUrl = () => getConfig().STUDIO_BASE_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
* @param {string} taxonomyDescription
* @param {File} file
* @returns {Promise<Object>}
*/
export async function importNewTaxonomy(taxonomyName, taxonomyDescription, file) {
const formData = new FormData();
formData.append('taxonomy_name', taxonomyName);
formData.append('taxonomy_description', taxonomyDescription);
formData.append('file', file);

const { data } = await getAuthenticatedHttpClient().post(
getTaxonomyImportNewApiUrl(),
formData,
);

return camelCaseObject(data);
}

/**
* Import tags to an existing taxonomy, overwriting existing tags
* @param {number} taxonomyId
* @param {File} file
* @returns {Promise<Object>}
*/
export async function importTags(taxonomyId, file) {
const formData = new FormData();
formData.append('file', file);

const { data } = await getAuthenticatedHttpClient().put(
getTagsImportApiUrl(taxonomyId),
formData,
);

return camelCaseObject(data);
}
48 changes: 48 additions & 0 deletions src/taxonomy/import-tags/data/api.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import MockAdapter from 'axios-mock-adapter';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';

import { tagImportMock, taxonomyImportMock } from '../__mocks__';

import {
getTaxonomyImportNewApiUrl,
getTagsImportApiUrl,
importNewTaxonomy,
importTags,
} from './api';

let axiosMock;

describe('import taxonomy api calls', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});

afterEach(() => {
jest.clearAllMocks();
});

it('should call import new taxonomy', async () => {
axiosMock.onPost(getTaxonomyImportNewApiUrl()).reply(201, taxonomyImportMock);
const result = await importNewTaxonomy('Taxonomy name', 'Taxonomy description');

expect(axiosMock.history.post[0].url).toEqual(getTaxonomyImportNewApiUrl());
expect(result).toEqual(taxonomyImportMock);
});

it('should call import tags', async () => {
axiosMock.onPut(getTagsImportApiUrl(1)).reply(200, tagImportMock);
const result = await importTags(1);

expect(axiosMock.history.put[0].url).toEqual(getTagsImportApiUrl(1));
expect(result).toEqual(tagImportMock);
});
});
120 changes: 120 additions & 0 deletions src/taxonomy/import-tags/data/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// ts-check
import messages from '../messages';
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);
});

export const importTaxonomy = async (intl) => {
/*
* 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 taxonomyName = getTaxonomyName();
if (taxonomyName == null) {
return;
}

const taxonomyDescription = getTaxonomyDescription();
if (taxonomyDescription == null) {
return;
}

importNewTaxonomy(taxonomyName, taxonomyDescription, file)
.then(() => {
alert(intl.formatMessage(messages.importTaxonomySuccess));
})
.catch((error) => {
alert(intl.formatMessage(messages.importTaxonomyError));
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);
});
};
Loading