Clean up Taxonomy API files/hooks/queries [FC-0036] (#850)

* chore: rename apiHooks.jsx to apihooks.js

* refactor: consolidate taxonomy API code

* fix: was not invalidating tags after import

* fix: UI was freezing while computing plan for large import files
This commit is contained in:
Braden MacDonald
2024-03-19 21:01:10 -07:00
committed by GitHub
parent 784a811ff8
commit 9a6e12bd3b
28 changed files with 703 additions and 826 deletions

View File

@@ -20,7 +20,7 @@ import {
useContentTaxonomyTagsData,
useContentData,
} from './data/apiHooks';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks';
import { useTaxonomyList } from '../taxonomy/data/apiHooks';
import Loading from '../generic/Loading';
/** @typedef {import("../taxonomy/data/types.mjs").TaxonomyData} TaxonomyData */
@@ -37,14 +37,9 @@ import Loading from '../generic/Loading';
*/
const ContentTagsDrawer = ({ id, onClose }) => {
const intl = useIntl();
// TODO: We can delete this when the iframe is no longer used on edx-platform
// TODO: We can delete 'params' when the iframe is no longer used on edx-platform
const params = useParams();
let contentId = id;
if (contentId === undefined) {
// TODO: We can delete this when the iframe is no longer used on edx-platform
contentId = params.contentId;
}
const contentId = id ?? params.contentId;
const org = extractOrgFromContentId(contentId);
@@ -74,18 +69,12 @@ const ContentTagsDrawer = ({ id, onClose }) => {
setStagedContentTags(prevStagedContentTags => ({ ...prevStagedContentTags, [taxonomyId]: tagsList }));
}, [setStagedContentTags]);
const useTaxonomyListData = () => {
const taxonomyListData = useTaxonomyListDataResponse(org);
const isTaxonomyListLoaded = useIsTaxonomyListDataLoaded(org);
return { taxonomyListData, isTaxonomyListLoaded };
};
const { data: contentData, isSuccess: isContentDataLoaded } = useContentData(contentId);
const {
data: contentTaxonomyTagsData,
isSuccess: isContentTaxonomyTagsLoaded,
} = useContentTaxonomyTagsData(contentId);
const { taxonomyListData, isTaxonomyListLoaded } = useTaxonomyListData();
const { data: taxonomyListData, isSuccess: isTaxonomyListLoaded } = useTaxonomyList(org);
let onCloseDrawer = onClose;
if (onCloseDrawer === undefined) {

View File

@@ -1,7 +1,12 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
act, render, fireEvent, screen,
act,
fireEvent,
render,
waitFor,
screen,
} from '@testing-library/react';
import ContentTagsDrawer from './ContentTagsDrawer';
@@ -10,7 +15,7 @@ import {
useContentData,
useTaxonomyTagsData,
} from './data/apiHooks';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from '../taxonomy/data/apiHooks';
import { getTaxonomyListData } from '../taxonomy/data/api';
import messages from './messages';
const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@7f47fe2dbcaf47c5a071671c741fe1ab';
@@ -23,6 +28,7 @@ jest.mock('react-router-dom', () => ({
}),
}));
// FIXME: replace these mocks with API mocks
jest.mock('./data/apiHooks', () => ({
useContentTaxonomyTagsData: jest.fn(() => ({
isSuccess: false,
@@ -46,20 +52,30 @@ jest.mock('./data/apiHooks', () => ({
})),
}));
jest.mock('../taxonomy/data/apiHooks', () => ({
useTaxonomyListDataResponse: jest.fn(),
useIsTaxonomyListDataLoaded: jest.fn(),
jest.mock('../taxonomy/data/api', () => ({
// By default, the mock taxonomy list will never load (promise never resolves):
getTaxonomyListData: jest.fn(),
}));
const queryClient = new QueryClient();
const RootWrapper = (params) => (
<IntlProvider locale="en" messages={{}}>
<ContentTagsDrawer {...params} />
<QueryClientProvider client={queryClient}>
<ContentTagsDrawer {...params} />
</QueryClientProvider>
</IntlProvider>
);
describe('<ContentTagsDrawer />', () => {
beforeEach(async () => {
await queryClient.resetQueries();
// By default, we mock the API call with a promise that never resolves.
// You can override this in specific test.
getTaxonomyListData.mockReturnValue(new Promise(() => {}));
});
const setupMockDataForStagedTagsTesting = () => {
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useContentTaxonomyTagsData.mockReturnValue({
isSuccess: true,
data: {
@@ -84,7 +100,7 @@ describe('<ContentTagsDrawer />', () => {
],
},
});
useTaxonomyListDataResponse.mockReturnValue({
getTaxonomyListData.mockResolvedValue({
results: [{
id: 123,
name: 'Taxonomy 1',
@@ -148,7 +164,6 @@ describe('<ContentTagsDrawer />', () => {
});
it('shows spinner before the taxonomy tags query is complete', async () => {
useIsTaxonomyListDataLoaded.mockReturnValue(false);
await act(async () => {
const { getAllByRole } = render(<RootWrapper />);
const spinner = getAllByRole('status')[1];
@@ -181,7 +196,6 @@ describe('<ContentTagsDrawer />', () => {
});
it('shows the taxonomies data including tag numbers after the query is complete', async () => {
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useContentTaxonomyTagsData.mockReturnValue({
isSuccess: true,
data: {
@@ -218,7 +232,7 @@ describe('<ContentTagsDrawer />', () => {
],
},
});
useTaxonomyListDataResponse.mockReturnValue({
getTaxonomyListData.mockResolvedValue({
results: [{
id: 123,
name: 'Taxonomy 1',
@@ -233,6 +247,7 @@ describe('<ContentTagsDrawer />', () => {
});
await act(async () => {
const { container, getByText } = render(<RootWrapper />);
await waitFor(() => { expect(getByText('Taxonomy 1')).toBeInTheDocument(); });
expect(getByText('Taxonomy 1')).toBeInTheDocument();
expect(getByText('Taxonomy 2')).toBeInTheDocument();
const tagCountBadges = container.getElementsByClassName('badge');
@@ -241,10 +256,11 @@ describe('<ContentTagsDrawer />', () => {
});
});
it('should test adding a content tag to the staged tags for a taxonomy', () => {
it('should test adding a content tag to the staged tags for a taxonomy', async () => {
setupMockDataForStagedTagsTesting();
const { container, getByText, getAllByText } = render(<RootWrapper />);
await waitFor(() => { expect(getByText('Taxonomy 1')).toBeInTheDocument(); });
// Expand the Taxonomy to view applied tags and "Add a tag" button
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
@@ -267,10 +283,11 @@ describe('<ContentTagsDrawer />', () => {
expect(getAllByText('Tag 3').length).toBe(2);
});
it('should test removing a staged content from a taxonomy', () => {
it('should test removing a staged content from a taxonomy', async () => {
setupMockDataForStagedTagsTesting();
const { container, getByText, getAllByText } = render(<RootWrapper />);
await waitFor(() => { expect(getByText('Taxonomy 1')).toBeInTheDocument(); });
// Expand the Taxonomy to view applied tags and "Add a tag" button
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];
@@ -297,7 +314,7 @@ describe('<ContentTagsDrawer />', () => {
expect(getAllByText('Tag 3').length).toBe(1);
});
it('should test clearing staged tags for a taxonomy', () => {
it('should test clearing staged tags for a taxonomy', async () => {
setupMockDataForStagedTagsTesting();
const {
@@ -306,6 +323,7 @@ describe('<ContentTagsDrawer />', () => {
getAllByText,
queryByText,
} = render(<RootWrapper />);
await waitFor(() => { expect(getByText('Taxonomy 1')).toBeInTheDocument(); });
// Expand the Taxonomy to view applied tags and "Add a tag" button
const expandToggle = container.getElementsByClassName('collapsible-trigger')[0];

View File

@@ -24,17 +24,15 @@ import { Helmet } from 'react-helmet';
import { useOrganizationListData } from '../generic/data/apiHooks';
import SubHeader from '../generic/sub-header/SubHeader';
import getPageHeadTitle from '../generic/utils';
import { getTaxonomyTemplateApiUrl } from './data/api';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './data/apiHooks';
import { ALL_TAXONOMIES, apiUrls, UNASSIGNED } from './data/api';
import { useImportNewTaxonomy, useTaxonomyList } from './data/apiHooks';
import { importTaxonomy } from './import-tags';
import messages from './messages';
import TaxonomyCard from './taxonomy-card';
const ALL_TAXONOMIES = 'All taxonomies';
const UNASSIGNED = 'Unassigned';
const TaxonomyListHeaderButtons = ({ canAddTaxonomy }) => {
const intl = useIntl();
const importMutation = useImportNewTaxonomy();
return (
<>
<OverlayTrigger
@@ -55,13 +53,13 @@ const TaxonomyListHeaderButtons = ({ canAddTaxonomy }) => {
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item
href={getTaxonomyTemplateApiUrl('csv')}
href={apiUrls.taxonomyTemplate('csv')}
data-testid="taxonomy-download-template-csv"
>
{intl.formatMessage(messages.downloadTemplateButtonCSVLabel)}
</Dropdown.Item>
<Dropdown.Item
href={getTaxonomyTemplateApiUrl('json')}
href={apiUrls.taxonomyTemplate('json')}
data-testid="taxonomy-download-template-json"
>
{intl.formatMessage(messages.downloadTemplateButtonJSONLabel)}
@@ -71,7 +69,7 @@ const TaxonomyListHeaderButtons = ({ canAddTaxonomy }) => {
</OverlayTrigger>
<Button
iconBefore={Add}
onClick={() => importTaxonomy(intl)}
onClick={() => importTaxonomy(intl, importMutation)}
data-testid="taxonomy-import-button"
disabled={!canAddTaxonomy}
>
@@ -154,8 +152,10 @@ const TaxonomyListPage = () => {
isSuccess: isOrganizationListLoaded,
} = useOrganizationListData();
const taxonomyListData = useTaxonomyListDataResponse(selectedOrgFilter);
const isLoaded = useIsTaxonomyListDataLoaded(selectedOrgFilter);
const {
data: taxonomyListData,
isSuccess: isLoaded,
} = useTaxonomyList(selectedOrgFilter);
const canAddTaxonomy = taxonomyListData?.canAddTaxonomy ?? false;
const getOrgSelect = () => (

View File

@@ -4,13 +4,17 @@ import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, fireEvent, render } from '@testing-library/react';
import {
act,
fireEvent,
render,
waitFor,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import initializeStore from '../store';
import { getTaxonomyTemplateApiUrl } from './data/api';
import { apiUrls } from './data/api';
import TaxonomyListPage from './TaxonomyListPage';
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './data/apiHooks';
import { importTaxonomy } from './import-tags';
import { TaxonomyContext } from './common/context';
@@ -27,14 +31,12 @@ const taxonomies = [{
tagsCount: 0,
}];
const organizationsListUrl = 'http://localhost:18010/organizations';
const listTaxonomiesUrl = 'http://localhost:18010/api/content_tagging/v1/taxonomies/?enabled=true';
const listTaxonomiesUnassignedUrl = `${listTaxonomiesUrl}&unassigned=true`;
const listTaxonomiesOrg1Url = `${listTaxonomiesUrl}&org=Org+1`;
const listTaxonomiesOrg2Url = `${listTaxonomiesUrl}&org=Org+2`;
const organizations = ['Org 1', 'Org 2'];
jest.mock('./data/apiHooks', () => ({
...jest.requireActual('./data/apiHooks'),
useTaxonomyListDataResponse: jest.fn(),
useIsTaxonomyListDataLoaded: jest.fn(),
}));
jest.mock('./import-tags', () => ({
importTaxonomy: jest.fn(),
}));
@@ -82,7 +84,8 @@ describe('<TaxonomyListPage />', () => {
});
it('shows the spinner before the query is complete', async () => {
useIsTaxonomyListDataLoaded.mockReturnValue(false);
// Simulate an API request that times out:
axiosMock.onGet(listTaxonomiesUrl).reply(new Promise(() => {}));
await act(async () => {
const { getByRole } = render(<RootWrapper />);
const spinner = getByRole('status');
@@ -91,61 +94,50 @@ describe('<TaxonomyListPage />', () => {
});
it('shows the data table after the query is complete', async () => {
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useTaxonomyListDataResponse.mockReturnValue({
results: taxonomies,
canAddTaxonomy: false,
});
axiosMock.onGet(listTaxonomiesUrl).reply(200, { results: taxonomies, canAddTaxonomy: false });
await act(async () => {
const { getByTestId } = render(<RootWrapper />);
const { getByTestId, queryByText } = render(<RootWrapper />);
await waitFor(() => { expect(queryByText('Loading')).toEqual(null); });
expect(getByTestId('taxonomy-card-1')).toBeInTheDocument();
});
});
it.each(['CSV', 'JSON'])('downloads the taxonomy template %s', async (fileFormat) => {
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useTaxonomyListDataResponse.mockReturnValue({
results: taxonomies,
canAddTaxonomy: false,
});
const { findByRole } = render(<RootWrapper />);
axiosMock.onGet(listTaxonomiesUrl).reply(200, { results: taxonomies, canAddTaxonomy: false });
const { findByRole, queryByText } = render(<RootWrapper />);
// Wait until data has been loaded and rendered:
await waitFor(() => { expect(queryByText('Loading')).toEqual(null); });
const templateMenu = await findByRole('button', { name: 'Download template' });
fireEvent.click(templateMenu);
const templateButton = await findByRole('link', { name: `${fileFormat} template` });
fireEvent.click(templateButton);
expect(templateButton.href).toBe(getTaxonomyTemplateApiUrl(fileFormat.toLowerCase()));
expect(templateButton.href).toBe(apiUrls.taxonomyTemplate(fileFormat.toLowerCase()));
});
it('disables the import taxonomy button if not permitted', async () => {
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useTaxonomyListDataResponse.mockReturnValue({
results: [],
canAddTaxonomy: false,
});
axiosMock.onGet(listTaxonomiesUrl).reply(200, { results: [], canAddTaxonomy: false });
const { getByRole } = render(<RootWrapper />);
const { queryByText, getByRole } = render(<RootWrapper />);
// Wait until data has been loaded and rendered:
await waitFor(() => { expect(queryByText('Loading')).toEqual(null); });
const importButton = getByRole('button', { name: 'Import' });
expect(importButton).toBeDisabled();
});
it('calls the import taxonomy action when the import button is clicked', async () => {
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useTaxonomyListDataResponse.mockReturnValue({
results: [],
canAddTaxonomy: true,
});
axiosMock.onGet(listTaxonomiesUrl).reply(200, { results: [], canAddTaxonomy: true });
const { getByRole } = render(<RootWrapper />);
const importButton = getByRole('button', { name: 'Import' });
expect(importButton).not.toBeDisabled();
// 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();
});
it('should show all "All taxonomies", "Unassigned" and org names in taxonomy org filter', async () => {
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useTaxonomyListDataResponse.mockReturnValue({
axiosMock.onGet(listTaxonomiesUrl).reply(200, {
results: [{
id: 1,
name: 'Taxonomy',
@@ -163,7 +155,10 @@ describe('<TaxonomyListPage />', () => {
getByText,
getByRole,
getAllByText,
queryByText,
} = render(<RootWrapper />);
// Wait until data has been loaded and rendered:
await waitFor(() => { expect(queryByText('Loading')).toEqual(null); });
expect(getByTestId('taxonomy-orgs-filter-selector')).toBeInTheDocument();
// Check that the default filter is set to 'All taxonomies' when page is loaded
@@ -184,13 +179,29 @@ describe('<TaxonomyListPage />', () => {
});
it('should fetch taxonomies with correct params for org filters', async () => {
useIsTaxonomyListDataLoaded.mockReturnValue(true);
useTaxonomyListDataResponse.mockReturnValue({
results: taxonomies,
axiosMock.onGet(listTaxonomiesUrl).reply(200, { results: taxonomies, canAddTaxonomy: false });
const defaults = {
id: 1,
showSystemBadge: false,
canChangeTaxonomy: true,
canDeleteTaxonomy: true,
tagsCount: 0,
description: 'Taxonomy description here',
};
axiosMock.onGet(listTaxonomiesUnassignedUrl).reply(200, {
canAddTaxonomy: false,
results: [{ name: 'Unassigned Taxonomy A', ...defaults }],
});
axiosMock.onGet(listTaxonomiesOrg1Url).reply(200, {
canAddTaxonomy: false,
results: [{ name: 'Org1 Taxonomy B', ...defaults }],
});
axiosMock.onGet(listTaxonomiesOrg2Url).reply(200, {
canAddTaxonomy: false,
results: [{ name: 'Org2 Taxonomy C', ...defaults }],
});
const { getByRole } = render(<RootWrapper />);
const { getByRole, getByText, queryByText } = render(<RootWrapper />);
// Open the taxonomies org filter select menu
const taxonomiesFilterSelectMenu = await getByRole('button', { name: 'All taxonomies' });
@@ -198,22 +209,28 @@ describe('<TaxonomyListPage />', () => {
// Check that the 'Unassigned' option is correctly called
fireEvent.click(getByRole('link', { name: 'Unassigned' }));
expect(useTaxonomyListDataResponse).toBeCalledWith('Unassigned');
await waitFor(() => {
expect(getByText('Unassigned Taxonomy A')).toBeInTheDocument();
});
// Open the taxonomies org filter select menu again
fireEvent.click(taxonomiesFilterSelectMenu);
// Check that the 'Org 1' option is correctly called
fireEvent.click(getByRole('link', { name: 'Org 1' }));
expect(useTaxonomyListDataResponse).toBeCalledWith('Org 1');
await waitFor(() => {
expect(getByText('Org1 Taxonomy B')).toBeInTheDocument();
});
// Open the taxonomies org filter select menu again
fireEvent.click(taxonomiesFilterSelectMenu);
// Check that the 'Org 2' option is correctly called
fireEvent.click(getByRole('link', { name: 'Org 2' }));
expect(useTaxonomyListDataResponse).toBeCalledWith('Org 2');
await waitFor(() => {
expect(queryByText('Org1 Taxonomy B')).not.toBeInTheDocument();
expect(queryByText('Org2 Taxonomy C')).toBeInTheDocument();
});
// Open the taxonomies org filter select menu again
fireEvent.click(taxonomiesFilterSelectMenu);
@@ -221,6 +238,8 @@ describe('<TaxonomyListPage />', () => {
// Check that the 'All' option is correctly called, it should show as
// 'All' rather than 'All taxonomies' in the select menu since its not selected
fireEvent.click(getByRole('link', { name: 'All' }));
expect(useTaxonomyListDataResponse).toBeCalledWith('All taxonomies');
await waitFor(() => {
expect(getByText(taxonomies[0].description)).toBeInTheDocument();
});
});
});

View File

@@ -3,71 +3,123 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getTaxonomyListApiUrl = (org) => {
const url = new URL('api/content_tagging/v1/taxonomies/', getApiBaseUrl());
url.searchParams.append('enabled', 'true');
if (org !== undefined) {
if (org === 'Unassigned') {
url.searchParams.append('unassigned', 'true');
} else if (org !== 'All taxonomies') {
url.searchParams.append('org', org);
}
const getTaxonomiesV1Endpoint = () => new URL('api/content_tagging/v1/taxonomies/', getApiBaseUrl()).href;
/**
* Helper method for creating URLs for the tagging/taxonomy API. Used only in this file.
* @param {string} path The subpath within the taxonomies "v1" REST API namespace
* @param {Record<string, string | number>} [searchParams] Query parameters to include
*/
const makeUrl = (path, searchParams) => {
const url = new URL(path, getTaxonomiesV1Endpoint());
if (searchParams) {
Object.entries(searchParams).forEach(([k, v]) => url.searchParams.append(k, String(v)));
}
return url.href;
};
export const getExportTaxonomyApiUrl = (pk, format) => new URL(
`api/content_tagging/v1/taxonomies/${pk}/export/?output_format=${format}&download=1`,
getApiBaseUrl(),
).href;
export const ALL_TAXONOMIES = '__all';
export const UNASSIGNED = '__unassigned';
export const getTaxonomyTemplateApiUrl = (format) => new URL(
`api/content_tagging/v1/taxonomies/import/template.${format}`,
getApiBaseUrl(),
).href;
/**
* Get the URL for a Taxonomy
* @param {number} pk
* @returns {string}
*/
export const getTaxonomyApiUrl = (pk) => new URL(`api/content_tagging/v1/taxonomies/${pk}/`, getApiBaseUrl()).href;
/** @satisfies {Record<string, (...args: any[]) => string>} */
export const apiUrls = {
/**
* Get the URL of the "list all taxonomies" endpoint
* @param {string} [org] Optionally, Filter the list to only show taxonomies assigned to this org
*/
taxonomyList(org) {
const params = {};
if (org !== undefined) {
if (org === UNASSIGNED) {
params.unassigned = 'true';
} else if (org !== ALL_TAXONOMIES) {
params.org = org;
}
}
return makeUrl('.', { enabled: 'true', ...params });
},
/**
* Get the URL of the API endpoint to download a taxonomy as a CSV/JSON file.
* @param {number} taxonomyId The ID of the taxonomy
* @param {'json'|'csv'} format Which format to use for the export
*/
exportTaxonomy: (taxonomyId, format) => makeUrl(`${taxonomyId}/export/`, { output_format: format, download: 1 }),
/**
* The the URL of the downloadable template file that shows how to format a
* taxonomy file.
* @param {'json'|'csv'} format The format requested
*/
taxonomyTemplate: (format) => makeUrl(`import/template.${format}`),
/**
* Get the URL for a Taxonomy
* @param {number} taxonomyId The ID of the taxonomy
*/
taxonomy: (taxonomyId) => makeUrl(`${taxonomyId}/`),
/**
* Get the URL for listing the tags of a taxonomy
* @param {number} taxonomyId
* @param {number} pageIndex Zero-indexed page number
* @param {*} pageSize How many tags per page to load
*/
tagList: (taxonomyId, pageIndex, pageSize) => makeUrl(`${taxonomyId}/tags/`, {
page: (pageIndex + 1), page_size: pageSize,
}),
/**
* Get _all_ tags below a given parent tag. This may be replaced with something more scalable in the future.
* @param {number} taxonomyId
* @param {string} parentTagValue
*/
allSubtagsOf: (taxonomyId, parentTagValue) => makeUrl(`${taxonomyId}/tags/`, {
// Load as deeply as we can
full_depth_threshold: 10000,
parent_tag: parentTagValue,
}),
/** URL to create a new taxonomy from an import file. */
createTaxonomyFromImport: () => makeUrl('import/'),
/**
* @param {number} taxonomyId
*/
tagsImport: (taxonomyId) => makeUrl(`${taxonomyId}/tags/import/`),
/**
* @param {number} taxonomyId
*/
tagsPlanImport: (taxonomyId) => makeUrl(`${taxonomyId}/tags/import/plan/`),
};
/**
* Get list of taxonomies.
* @param {string} org Optioanl organization query param
* @param {string} [org] Filter the list to only show taxonomies assigned to this org
* @returns {Promise<import("./types.mjs").TaxonomyListData>}
*/
export async function getTaxonomyListData(org) {
const { data } = await getAuthenticatedHttpClient().get(getTaxonomyListApiUrl(org));
const { data } = await getAuthenticatedHttpClient().get(apiUrls.taxonomyList(org));
return camelCaseObject(data);
}
/**
* Delete a Taxonomy
* @param {number} pk
* @returns {Promise<Object>}
* @param {number} taxonomyId
* @returns {Promise<void>}
*/
export async function deleteTaxonomy(pk) {
await getAuthenticatedHttpClient().delete(getTaxonomyApiUrl(pk));
export async function deleteTaxonomy(taxonomyId) {
await getAuthenticatedHttpClient().delete(apiUrls.taxonomy(taxonomyId));
}
/** Get a Taxonomy
* @param {number} pk
* @returns {Promise<import("./types.mjs").TaxonomyData>}
*/
export async function getTaxonomy(pk) {
const { data } = await getAuthenticatedHttpClient().get(getTaxonomyApiUrl(pk));
/**
* Get metadata about a Taxonomy
* @param {number} taxonomyId The ID of the taxonomy to get
* @returns {Promise<import("./types.mjs").TaxonomyData>}
*/
export async function getTaxonomy(taxonomyId) {
const { data } = await getAuthenticatedHttpClient().get(apiUrls.taxonomy(taxonomyId));
return camelCaseObject(data);
}
/**
* Downloads the file of the exported taxonomy
* @param {number} pk
* @param {string} format
* @param {number} taxonomyId The ID of the taxonomy
* @param {'json'|'csv'} format Which format to use for the export file.
* @returns {void}
*/
export function getTaxonomyExportFile(pk, format) {
window.location.href = getExportTaxonomyApiUrl(pk, format);
export function getTaxonomyExportFile(taxonomyId, format) {
window.location.href = apiUrls.exportTaxonomy(taxonomyId, format);
}

View File

@@ -1,3 +1,4 @@
// @ts-check
import MockAdapter from 'axios-mock-adapter';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
@@ -5,11 +6,9 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { taxonomyListMock } from '../__mocks__';
import {
getExportTaxonomyApiUrl,
apiUrls,
getTaxonomyExportFile,
getTaxonomyListApiUrl,
getTaxonomyListData,
getTaxonomyApiUrl,
getTaxonomy,
deleteTaxonomy,
} from './api';
@@ -17,7 +16,6 @@ import {
let axiosMock;
describe('taxonomy api calls', () => {
const { location } = window;
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
@@ -35,50 +33,47 @@ describe('taxonomy api calls', () => {
jest.clearAllMocks();
});
beforeAll(() => {
delete window.location;
window.location = {
href: '',
};
});
afterAll(() => {
window.location = location;
});
it.each([
undefined,
'All taxonomies',
'Unassigned',
'testOrg',
])('should get taxonomy list data for \'%s\' org filter', async (org) => {
axiosMock.onGet(getTaxonomyListApiUrl(org)).reply(200, taxonomyListMock);
axiosMock.onGet(apiUrls.taxonomyList(org)).reply(200, taxonomyListMock);
const result = await getTaxonomyListData(org);
expect(axiosMock.history.get[0].url).toEqual(getTaxonomyListApiUrl(org));
expect(axiosMock.history.get[0].url).toEqual(apiUrls.taxonomyList(org));
expect(result).toEqual(taxonomyListMock);
});
it('should delete a taxonomy', async () => {
axiosMock.onDelete(getTaxonomyApiUrl()).reply(200);
await deleteTaxonomy();
const taxonomyId = 123;
axiosMock.onDelete(apiUrls.taxonomy(taxonomyId)).reply(200);
await deleteTaxonomy(taxonomyId);
expect(axiosMock.history.delete[0].url).toEqual(getTaxonomyApiUrl());
expect(axiosMock.history.delete[0].url).toEqual(apiUrls.taxonomy(taxonomyId));
});
it('should call get taxonomy', async () => {
axiosMock.onGet(getTaxonomyApiUrl(1)).reply(200);
axiosMock.onGet(apiUrls.taxonomy(1)).reply(200);
await getTaxonomy(1);
expect(axiosMock.history.get[0].url).toEqual(getTaxonomyApiUrl(1));
expect(axiosMock.history.get[0].url).toEqual(apiUrls.taxonomy(1));
});
it('Export should set window.location.href correctly', () => {
const origLocation = window.location;
// @ts-ignore
delete window.location;
// @ts-ignore
window.location = { href: '' };
const pk = 1;
const format = 'json';
getTaxonomyExportFile(pk, format);
expect(window.location.href).toEqual(apiUrls.exportTaxonomy(pk, format));
expect(window.location.href).toEqual(getExportTaxonomyApiUrl(pk, format));
// Restore the location object of window:
window.location = origLocation;
});
});

View File

@@ -0,0 +1,206 @@
// @ts-check
/**
* This is a file used especially in this `taxonomy` module.
*
* We are using a new approach, using `useQuery` to build and execute the queries to the APIs.
* This approach accelerates the development.
*
* In this file you will find two types of hooks:
* - Hooks that builds the query with `useQuery`. These hooks are not used outside of this file.
* Ex. useTaxonomyListData.
* - Hooks that calls the query hook, prepare and return the data.
* Ex. useTaxonomyListDataResponse & useIsTaxonomyListDataLoaded.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { camelCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { apiUrls, ALL_TAXONOMIES } from './api';
import * as api from './api';
// Query key patterns. Allows an easy way to clear all data related to a given taxonomy.
// https://github.com/openedx/frontend-app-admin-portal/blob/2ba315d/docs/decisions/0006-tanstack-react-query.rst
// Inspired by https://tkdodo.eu/blog/effective-react-query-keys#use-query-key-factories.
export const taxonomyQueryKeys = {
all: ['taxonomies'],
/**
* Key for the list of taxonomies, optionally filtered by org.
* @param {string} [org] Which org we fetched the taxonomy list for (optional)
*/
taxonomyList: (org) => [
...taxonomyQueryKeys.all, 'taxonomyList', ...(org && org !== ALL_TAXONOMIES ? [org] : []),
],
/**
* Base key for data specific to a single taxonomy. No data is stored directly in this key.
* @param {number} taxonomyId ID of the taxonomy
*/
taxonomy: (taxonomyId) => [...taxonomyQueryKeys.all, 'taxonomy', taxonomyId],
/**
* @param {number} taxonomyId ID of the taxonomy
*/
taxonomyMetadata: (taxonomyId) => [...taxonomyQueryKeys.taxonomy(taxonomyId), 'metadata'],
/**
* @param {number} taxonomyId ID of the taxonomy
*/
taxonomyTagList: (taxonomyId) => [...taxonomyQueryKeys.taxonomy(taxonomyId), 'tags'],
/**
* @param {number} taxonomyId ID of the taxonomy
* @param {number} pageIndex Which page of tags to load (zero-based)
* @param {number} pageSize
*/
taxonomyTagListPage: (taxonomyId, pageIndex, pageSize) => [
...taxonomyQueryKeys.taxonomyTagList(taxonomyId), 'page', pageIndex, pageSize,
],
/**
* Query for loading _all_ the subtags of a particular parent tag
* @param {number} taxonomyId ID of the taxonomy
* @param {string} parentTagValue
*/
taxonomyTagSubtagsList: (taxonomyId, parentTagValue) => [
...taxonomyQueryKeys.taxonomyTagList(taxonomyId), 'subtags', parentTagValue,
],
/**
* @param {number} taxonomyId ID of the taxonomy
* @param {string} fileId Some string to uniquely identify the file we want to upload
*/
importPlan: (taxonomyId, fileId) => [...taxonomyQueryKeys.all, 'importPlan', taxonomyId, fileId],
};
/**
* Builds the query to get the taxonomy list
* @param {string} [org] Filter the list to only show taxonomies assigned to this org
*/
export const useTaxonomyList = (org) => (
useQuery({
queryKey: taxonomyQueryKeys.taxonomyList(org),
queryFn: () => api.getTaxonomyListData(org),
})
);
/**
* Builds the mutation to delete a taxonomy.
* @returns A function that can be used to delete the taxonomy.
*/
export const useDeleteTaxonomy = () => {
const queryClient = useQueryClient();
const { mutateAsync } = useMutation({
/** @type {import("@tanstack/react-query").MutateFunction<any, any, {pk: number}>} */
mutationFn: async ({ pk }) => api.deleteTaxonomy(pk),
onSettled: (_d, _e, args) => {
queryClient.invalidateQueries({ queryKey: taxonomyQueryKeys.taxonomyList() });
queryClient.removeQueries({ queryKey: taxonomyQueryKeys.taxonomy(args.pk) });
},
});
return mutateAsync;
};
/** Builds the query to get the taxonomy detail
* @param {number} taxonomyId
*/
export const useTaxonomyDetails = (taxonomyId) => useQuery({
queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId),
queryFn: () => api.getTaxonomy(taxonomyId),
});
/**
* Use this mutation to import a new taxonomy.
*/
export const useImportNewTaxonomy = () => {
const queryClient = useQueryClient();
return useMutation({
/**
* @type {import("@tanstack/react-query").MutateFunction<
* import("./types.mjs").TaxonomyData,
* any,
* {
* name: string,
* exportId: string,
* description: string,
* file: File,
* }
* >}
*/
mutationFn: async ({
name, exportId, description, file,
}) => {
const formData = new FormData();
formData.append('taxonomy_name', name);
formData.append('taxonomy_export_id', exportId);
formData.append('taxonomy_description', description);
formData.append('file', file);
const { data } = await getAuthenticatedHttpClient().post(apiUrls.createTaxonomyFromImport(), formData);
return camelCaseObject(data);
},
onSuccess: (data) => {
// There's a new taxonomy, so the list of taxonomies needs to be refreshed:
queryClient.invalidateQueries({
queryKey: taxonomyQueryKeys.taxonomyList(),
});
queryClient.setQueryData(taxonomyQueryKeys.taxonomyMetadata(data.id), data);
},
});
};
/**
* Build the mutation to import tags to an existing taxonomy
*/
export const useImportTags = () => {
const queryClient = useQueryClient();
return useMutation({
/**
* @type {import("@tanstack/react-query").MutateFunction<
* import("./types.mjs").TaxonomyData,
* any,
* {
* taxonomyId: number,
* file: File,
* }
* >}
*/
mutationFn: async ({ taxonomyId, file }) => {
const formData = new FormData();
formData.append('file', file);
try {
const { data } = await getAuthenticatedHttpClient().put(apiUrls.tagsImport(taxonomyId), formData);
return camelCaseObject(data);
} catch (/** @type {any} */ err) {
throw new Error(err.response?.data?.error || err.message);
}
},
onSuccess: (data) => {
queryClient.invalidateQueries({
queryKey: taxonomyQueryKeys.taxonomyTagList(data.id),
});
// In the metadata, 'tagsCount' (and possibly other fields) will have changed:
queryClient.setQueryData(taxonomyQueryKeys.taxonomyMetadata(data.id), data);
},
});
};
/**
* Preview the results of importing the given file into an existing taxonomy.
* @param {number} taxonomyId The ID of the taxonomy whose tags we're updating.
* @param {File|null} file The file that we want to import
*/
export const useImportPlan = (taxonomyId, file) => useQuery({
queryKey: taxonomyQueryKeys.importPlan(taxonomyId, file ? `${file.name}${file.lastModified}${file.size}` : ''),
/**
* @type {import("@tanstack/react-query").QueryFunction<string|null>}
*/
queryFn: async () => {
if (file === null) {
return null;
}
const formData = new FormData();
formData.append('file', file);
try {
const { data } = await getAuthenticatedHttpClient().put(apiUrls.tagsPlanImport(taxonomyId), formData);
return /** @type {string} */(data.plan);
} catch (/** @type {any} */ err) {
throw new Error(err.response?.data?.error || err.message);
}
},
retry: false, // If there's an error, it's probably a real problem with the file. Don't try again several times!
});

View File

@@ -1,106 +0,0 @@
// @ts-check
/**
* This is a file used especially in this `taxonomy` module.
*
* We are using a new approach, using `useQuery` to build and execute the queries to the APIs.
* This approach accelerates the development.
*
* In this file you will find two types of hooks:
* - Hooks that builds the query with `useQuery`. These hooks are not used outside of this file.
* Ex. useTaxonomyListData.
* - Hooks that calls the query hook, prepare and return the data.
* Ex. useTaxonomyListDataResponse & useIsTaxonomyListDataLoaded.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getTaxonomyListData, deleteTaxonomy, getTaxonomy } from './api';
/**
* Builds the query to get the taxonomy list
* @param {string} org Optional organization query param
*/
const useTaxonomyListData = (org) => (
useQuery({
queryKey: ['taxonomyList', org],
queryFn: () => getTaxonomyListData(org),
})
);
/**
* Builds the mutation to delete a taxonomy.
* @returns An object with the mutation configuration.
*/
export const useDeleteTaxonomy = () => {
const queryClient = useQueryClient();
const { mutate } = useMutation({
/** @type {import("@tanstack/react-query").MutateFunction<any, any, {pk: number}>} */
mutationFn: async ({ pk }) => deleteTaxonomy(pk),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['taxonomyList'] });
},
});
return mutate;
};
/** Builds the query to get the taxonomy detail
* @param {number} taxonomyId
*/
const useTaxonomyDetailData = (taxonomyId) => (
useQuery({
queryKey: ['taxonomyDetail', taxonomyId],
queryFn: async () => getTaxonomy(taxonomyId),
})
);
/**
* Gets the taxonomy list data
* @param {string} org Optional organization query param
* @returns {import("./types.mjs").TaxonomyListData | undefined}
*/
export const useTaxonomyListDataResponse = (org) => {
const response = useTaxonomyListData(org);
if (response.status === 'success') {
return { ...response.data, refetch: response.refetch };
}
return undefined;
};
/**
* Returns the status of the taxonomy list query
* @param {string} org Optional organization param
* @returns {boolean}
*/
export const useIsTaxonomyListDataLoaded = (org) => (
useTaxonomyListData(org).status === 'success'
);
/**
* @param {number} taxonomyId
* @returns {Pick<import('@tanstack/react-query').UseQueryResult, "error" | "isError" | "isFetched" | "isSuccess">}
*/
export const useTaxonomyDetailDataStatus = (taxonomyId) => {
const {
isError,
error,
isFetched,
isSuccess,
} = useTaxonomyDetailData(taxonomyId);
return {
isError,
error,
isFetched,
isSuccess,
};
};
/**
* @param {number} taxonomyId
* @returns {import("./types.mjs").TaxonomyData | undefined}
*/
export const useTaxonomyDetailDataResponse = (taxonomyId) => {
const { isSuccess, data } = useTaxonomyDetailData(taxonomyId);
if (isSuccess) {
return data;
}
return undefined;
};

View File

@@ -1,78 +1,110 @@
import { useQuery, useMutation } from '@tanstack/react-query';
import { act } from '@testing-library/react';
// @ts-check
import React from 'react'; // Required to use JSX syntax without type errors
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { waitFor } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import MockAdapter from 'axios-mock-adapter';
import { apiUrls } from './api';
import {
useTaxonomyListDataResponse,
useIsTaxonomyListDataLoaded,
useDeleteTaxonomy,
useImportPlan,
useImportTags,
useImportNewTaxonomy,
} from './apiHooks';
import { deleteTaxonomy } from './api';
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn(),
useMutation: jest.fn(),
useQueryClient: jest.fn(),
}));
let axiosMock;
jest.mock('./api', () => ({
deleteTaxonomy: jest.fn(),
}));
/*
* TODO: We can refactor this test: Mock the API response using axiosMock.
* Ref: https://github.com/openedx/frontend-app-course-authoring/pull/684#issuecomment-1847694090
*/
describe('useTaxonomyListDataResponse', () => {
it('should return data when status is success', () => {
useQuery.mockReturnValueOnce({ status: 'success', data: { data: 'data' } });
const result = useTaxonomyListDataResponse();
expect(result).toEqual({ data: 'data' });
});
it('should return undefined when status is not success', () => {
useQuery.mockReturnValueOnce({ status: 'error' });
const result = useTaxonomyListDataResponse();
expect(result).toBeUndefined();
});
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
describe('useIsTaxonomyListDataLoaded', () => {
it('should return true when status is success', () => {
useQuery.mockReturnValueOnce({ status: 'success' });
const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
const result = useIsTaxonomyListDataLoaded();
const emptyFile = new File([], 'empty.csv');
expect(result).toBe(true);
});
it('should return false when status is not success', () => {
useQuery.mockReturnValueOnce({ status: 'error' });
const result = useIsTaxonomyListDataLoaded();
expect(result).toBe(false);
});
});
describe('useDeleteTaxonomy', () => {
it('should call the delete function', async () => {
useMutation.mockReturnValueOnce({ mutate: jest.fn() });
const mutation = useDeleteTaxonomy();
mutation();
expect(useMutation).toBeCalled();
const [config] = useMutation.mock.calls[0];
const { mutationFn } = config;
await act(async () => {
await mutationFn({ pk: 1 });
expect(deleteTaxonomy).toBeCalledWith(1);
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 () => {
const mockResult = {
id: 8,
name: 'Taxonomy name',
exportId: 'taxonomy_export_id',
description: 'Taxonomy description',
};
axiosMock.onPost(apiUrls.createTaxonomyFromImport()).reply(201, mockResult);
const { result } = renderHook(() => useImportNewTaxonomy(), { wrapper });
const mutateResult = await result.current.mutateAsync({
name: 'Taxonomy name',
description: 'Taxonomy description',
exportId: 'taxonomy_export_id',
file: emptyFile,
});
expect(axiosMock.history.post[0].url).toEqual(apiUrls.createTaxonomyFromImport());
expect(mutateResult).toEqual(mockResult);
});
it('should call import tags', async () => {
const taxonomy = { id: 1, name: 'taxonomy name' };
axiosMock.onPut(apiUrls.tagsImport(1)).reply(200, taxonomy);
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const mockSetQueryData = jest.spyOn(queryClient, 'setQueryData');
const { result } = renderHook(() => useImportTags(), { wrapper });
await result.current.mutateAsync({ taxonomyId: 1, file: emptyFile });
expect(axiosMock.history.put[0].url).toEqual(apiUrls.tagsImport(1));
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['taxonomies', 'taxonomy', 1, 'tags'] });
expect(mockSetQueryData).toHaveBeenCalledWith(['taxonomies', 'taxonomy', 1, 'metadata'], taxonomy);
});
it('should call plan import tags', async () => {
axiosMock.onPut(apiUrls.tagsPlanImport(1)).reply(200, { plan: 'some plan' });
const { result } = renderHook(() => useImportPlan(1, emptyFile), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBeFalsy();
});
expect(axiosMock.history.put[0].url).toEqual(apiUrls.tagsPlanImport(1));
expect(result.current.data).toEqual('some plan');
});
it('should handle errors in plan import tags', async () => {
axiosMock.onPut(apiUrls.tagsPlanImport(1)).reply(400, { error: 'test error' });
const { result } = renderHook(() => useImportPlan(1, emptyFile), { wrapper });
await waitFor(() => {
expect(result.current.isError).toBeTruthy();
});
expect(result.current.error).toEqual(Error('test error'));
expect(axiosMock.history.put[0].url).toEqual(apiUrls.tagsPlanImport(1));
});
});

View File

@@ -1,7 +1,7 @@
// @ts-check
/**
* @typedef {Object} TaxonomyData
* @typedef {Object} TaxonomyData Metadata about a taxonomy
* @property {number} id
* @property {string} name
* @property {string} description
@@ -20,14 +20,13 @@
*/
/**
* @typedef {Object} TaxonomyListData
* @typedef {Object} TaxonomyListData The list of taxonomies
* @property {string} next
* @property {string} previous
* @property {number} count
* @property {number} numPages
* @property {number} currentPage
* @property {number} start
* @property {function} refetch
* @property {boolean} canAddTaxonomy
* @property {TaxonomyData[]} results
*/

View File

@@ -18,7 +18,7 @@ const ExportModal = ({
onClose,
}) => {
const intl = useIntl();
const [outputFormat, setOutputFormat] = useState('csv');
const [outputFormat, setOutputFormat] = useState(/** @type {'csv'|'json'} */('csv'));
const onClickExport = React.useCallback(() => {
onClose();

View File

@@ -1,5 +1,5 @@
// @ts-check
import React, { useState, useContext } from 'react';
import React, { useState, useContext, useMemo } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
useToggle,
@@ -22,10 +22,11 @@ import {
import PropTypes from 'prop-types';
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 { planImportTags, useImportTags } from './data/api';
import { useImportTags, useImportPlan } from '../data/apiHooks';
import messages from './messages';
const linebreak = <> <br /> <br /> </>;
@@ -73,20 +74,17 @@ const UploadStep = ({
file,
setFile,
importPlanError,
setImportPlanError,
}) => {
const intl = useIntl();
/** @type {(args: {fileData: FormData}) => void} */
const handleFileLoad = ({ fileData }) => {
setFile(fileData.get('file'));
setImportPlanError(null);
};
const clearFile = (e) => {
e.stopPropagation();
setFile(null);
setImportPlanError(null);
};
return (
@@ -147,7 +145,6 @@ UploadStep.propTypes = {
}),
setFile: PropTypes.func.isRequired,
importPlanError: PropTypes.string,
setImportPlanError: PropTypes.func.isRequired,
};
UploadStep.defaultProps = {
@@ -228,35 +225,28 @@ const ImportTagsWizard = ({
const [file, setFile] = useState(/** @type {null|File} */ (null));
const [importPlan, setImportPlan] = useState(/** @type {null|string[]} */ (null));
const [importPlanError, setImportPlanError] = useState(null);
const [isDialogDisabled, disableDialog, enableDialog] = useToggle(false);
const importPlanResult = useImportPlan(taxonomy.id, file);
const importPlan = useMemo(() => {
if (!importPlanResult.data) {
return null;
}
let planArrayTemp = importPlanResult.data.split('\n');
planArrayTemp = planArrayTemp.slice(2); // Removes the first two lines
planArrayTemp = planArrayTemp.slice(0, -1); // Removes the last line
const planArray = planArrayTemp
.filter((line) => !(line.includes('No changes'))) // Removes the "No changes" lines
.map((line) => line.split(':')[1].trim()); // Get only the action message
return /** @type {string[]} */(planArray);
}, [importPlanResult.data]);
const importTagsMutation = useImportTags();
const generatePlan = async () => {
disableDialog();
try {
if (file) {
const plan = await planImportTags(taxonomy.id, file);
let planArrayTemp = plan.split('\n');
planArrayTemp = planArrayTemp.slice(2); // Removes the first two lines
planArrayTemp = planArrayTemp.slice(0, -1); // Removes the last line
const planArray = planArrayTemp
.filter((line) => !(line.includes('No changes'))) // Removes the "No changes" lines
.map((line) => line.split(':')[1].trim()); // Get only the action message
setImportPlan(planArray);
setImportPlanError(null);
setCurrentStep('plan');
}
} catch (/** @type {any} */ error) {
setImportPlan(null);
setImportPlanError(error.message);
} finally {
enableDialog();
}
};
const generatePlan = React.useCallback(() => {
setCurrentStep('plan');
}, []);
const confirmImportTags = async () => {
disableDialog();
@@ -326,8 +316,8 @@ const ImportTagsWizard = ({
onClose={onClose}
size="lg"
>
{isDialogDisabled && (
// This div is used to prevent the user from interacting with the dialog while it is disabled
{(isDialogDisabled) && (
// This div is used to prevent the user from interacting with the dialog while the import is happening
<div className="position-absolute w-100 h-100 d-block zindex-9" />
)}
@@ -341,8 +331,7 @@ const ImportTagsWizard = ({
<UploadStep
file={file}
setFile={setFile}
importPlanError={importPlanError}
setImportPlanError={setImportPlanError}
importPlanError={/** @type {Error|undefined} */(importPlanResult.error)?.message}
/>
<PlanStep importPlan={importPlan} />
<ConfirmStep importPlan={importPlan} />
@@ -369,11 +358,16 @@ const ImportTagsWizard = ({
<Button variant="tertiary" onClick={onClose}>
{intl.formatMessage(messages.importWizardButtonCancel)}
</Button>
<LoadingButton
label={intl.formatMessage(messages.importWizardButtonImport)}
disabled={!file || !!importPlanError}
onClick={generatePlan}
/>
{
importPlanResult.isLoading ? <LoadingSpinner />
: (
<LoadingButton
label={intl.formatMessage(messages.importWizardButtonImport)}
disabled={!file || importPlanResult.isLoading || !!importPlanResult.error}
onClick={generatePlan}
/>
)
}
</Stepper.ActionRow>
<Stepper.ActionRow eventKey="plan">

View File

@@ -1,9 +1,12 @@
import MockAdapter from 'axios-mock-adapter';
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
act,
fireEvent,
render,
waitFor,
@@ -13,29 +16,18 @@ import PropTypes from 'prop-types';
import initializeStore from '../../store';
import { getTaxonomyExportFile } from '../data/api';
import { TaxonomyContext } from '../common/context';
import { planImportTags } from './data/api';
import ImportTagsWizard from './ImportTagsWizard';
let store;
const queryClient = new QueryClient();
let axiosMock;
jest.mock('../data/api', () => ({
...jest.requireActual('../data/api'),
getTaxonomyExportFile: jest.fn(),
}));
const mockUseImportTagsMutate = jest.fn();
jest.mock('./data/api', () => ({
...jest.requireActual('./data/api'),
planImportTags: jest.fn(),
useImportTags: jest.fn(() => ({
...jest.requireActual('./data/api').useImportTags(),
mutateAsync: mockUseImportTagsMutate,
})),
}));
const mockSetToastMessage = jest.fn();
const mockSetAlertProps = jest.fn();
const context = {
@@ -45,6 +37,9 @@ const context = {
setAlertProps: mockSetAlertProps,
};
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 taxonomy = {
id: 1,
name: 'Test Taxonomy',
@@ -77,6 +72,7 @@ describe('<ImportTagsWizard />', () => {
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
afterEach(() => {
@@ -129,7 +125,7 @@ describe('<ImportTagsWizard />', () => {
expect(getByTestId('upload-step')).toBeInTheDocument();
// Continue flow
const importButton = getByRole('button', { name: 'Import' });
let importButton = getByRole('button', { name: 'Import' });
expect(importButton).toHaveAttribute('aria-disabled', 'true');
// Invalid file type
@@ -138,48 +134,56 @@ describe('<ImportTagsWizard />', () => {
expect(getByTestId('dropzone')).toBeInTheDocument();
expect(importButton).toHaveAttribute('aria-disabled', 'true');
const makeJson = (filename) => new File(['{}'], filename, { type: 'application/json' });
// Correct file type
const fileJson = new File(['file contents'], 'example.json', { type: 'application/gzip' });
fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [fileJson], types: ['Files'] } });
axiosMock.onPut(planImportUrl).replyOnce(200, { plan: 'Import plan' });
fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [makeJson('example1.json')], types: ['Files'] } });
expect(await findByTestId('file-info')).toBeInTheDocument();
expect(getByText('example.json')).toBeInTheDocument();
expect(importButton).not.toHaveAttribute('aria-disabled', 'true');
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: [fileJson], types: ['Files'] } });
// Simulate error (note: React-Query may start to retrieve the import plan as soon as the file is selected)
axiosMock.onPut(planImportUrl).replyOnce(400, { error: 'Test error - details here' });
fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [makeJson('example2.json')], types: ['Files'] } });
expect(await findByTestId('file-info')).toBeInTheDocument();
// Simulate error
planImportTags.mockRejectedValueOnce(new Error('Test error'));
expect(importButton).not.toHaveAttribute('aria-disabled', 'true');
fireEvent.click(importButton);
// Check error message
expect(planImportTags).toHaveBeenCalledWith(taxonomy.id, fileJson);
expect(await findByText('Test error')).toBeInTheDocument();
const errorAlert = getByText('Test error');
await waitFor(async () => {
// Note: import button gets re-created after showing a spinner while the import plan is loaded.
importButton = getByRole('button', { name: 'Import' });
expect(await findByText('Test error - details here')).toBeInTheDocument();
// Because of the import error, we cannot proceed to the next step
expect(importButton).toHaveAttribute('aria-disabled', 'true');
});
const errorAlert = getByText('Test error - details here');
// Reselect file to clear the error
fireEvent.click(getByTestId('clear-file-button'));
expect(errorAlert).not.toBeInTheDocument();
fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [fileJson], types: ['Files'] } });
// Now simulate uploading a correct file.
const expectedPlan = 'Import plan for Test import taxonomy\n'
+ '--------------------------------\n'
+ '#1: Create a new tag with values (external_id=tag_1, value=Tag 1, parent_id=None).\n'
+ '#2: Create a new tag with values (external_id=tag_2, value=Tag 2, parent_id=None).\n'
+ '#3: Create a new tag with values (external_id=tag_3, value=Tag 3, parent_id=None).\n'
+ '#4: Create a new tag with values (external_id=tag_4, value=Tag 4, parent_id=None).\n'
+ '#5: Delete tag (external_id=old_tag_1)\n'
+ '#6: Delete tag (external_id=old_tag_2)\n';
axiosMock.onPut(planImportUrl).replyOnce(200, { plan: expectedPlan });
fireEvent.drop(getByTestId('dropzone'), { dataTransfer: { files: [makeJson('example3.json')], types: ['Files'] } });
expect(await findByTestId('file-info')).toBeInTheDocument();
expect(importButton).not.toHaveAttribute('aria-disabled', 'true');
const expectedPlan = 'Import plan for Test import taxonomy\n'
+ '--------------------------------\n'
+ '#1: Create a new tag with values (external_id=tag_1, value=Tag 1, parent_id=None).\n'
+ '#2: Create a new tag with values (external_id=tag_2, value=Tag 2, parent_id=None).\n'
+ '#3: Create a new tag with values (external_id=tag_3, value=Tag 3, parent_id=None).\n'
+ '#4: Create a new tag with values (external_id=tag_4, value=Tag 4, parent_id=None).\n'
+ '#5: Delete tag (external_id=old_tag_1)\n'
+ '#6: Delete tag (external_id=old_tag_2)\n';
planImportTags.mockResolvedValueOnce(expectedPlan);
await waitFor(() => {
// Note: import button gets re-created after showing a spinner while the import plan is loaded.
importButton = getByRole('button', { name: 'Import' });
expect(importButton).not.toHaveAttribute('aria-disabled', 'true');
});
fireEvent.click(importButton);
@@ -188,7 +192,6 @@ describe('<ImportTagsWizard />', () => {
// Test back button
fireEvent.click(getByTestId('back-button'));
expect(getByTestId('upload-step')).toBeInTheDocument();
planImportTags.mockResolvedValueOnce(expectedPlan);
fireEvent.click(getByRole('button', { name: 'Import' }));
expect(await findByTestId('plan-step')).toBeInTheDocument();
@@ -205,9 +208,9 @@ describe('<ImportTagsWizard />', () => {
expect(getByTestId('confirm-step')).toBeInTheDocument();
if (expectedResult === 'success') {
mockUseImportTagsMutate.mockResolvedValueOnce({});
axiosMock.onPut(doImportUrl).replyOnce(200, {});
} else {
mockUseImportTagsMutate.mockRejectedValueOnce(new Error('Test error'));
axiosMock.onPut(doImportUrl).replyOnce(400, { error: 'Test error' });
}
const confirmButton = getByRole('button', { name: 'Yes, import file' });
@@ -215,24 +218,24 @@ describe('<ImportTagsWizard />', () => {
expect(confirmButton).not.toHaveAttribute('aria-disabled', 'true');
});
fireEvent.click(confirmButton);
await waitFor(() => {
expect(mockUseImportTagsMutate).toHaveBeenCalledWith({ taxonomyId: taxonomy.id, file: fileJson });
});
act(() => { fireEvent.click(confirmButton); });
if (expectedResult === 'success') {
// Toast message shown
expect(mockSetToastMessage).toBeCalledWith(`"${taxonomy.name}" updated`);
await waitFor(() => {
expect(mockSetToastMessage).toBeCalledWith(`"${taxonomy.name}" updated`);
});
} else {
// Alert message shown
expect(mockSetAlertProps).toBeCalledWith(
expect.objectContaining({
variant: 'danger',
title: 'Import error',
description: 'Test error',
}),
);
await waitFor(() => {
expect(mockSetAlertProps).toBeCalledWith(
expect.objectContaining({
variant: 'danger',
title: 'Import error',
description: 'Test error',
}),
);
});
}
});
});

View File

@@ -1 +0,0 @@
export { default as taxonomyImportMock } from './taxonomyImportMock'; // eslint-disable-line import/prefer-default-export

View File

@@ -1,5 +0,0 @@
export default {
name: 'Taxonomy name',
exportId: 'taxonomy_export_id',
description: 'Taxonomy description',
};

View File

@@ -1,114 +0,0 @@
// @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;
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;
/**
* @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
* @param {string} taxonomyDescription
* @param {File} file
* @returns {Promise<import('../../data/types.mjs').TaxonomyData>}
*/
export async function importNewTaxonomy(taxonomyName, taxonomyExportId, taxonomyDescription, file) {
// ToDo: transform this to use react-query like useImportTags
const formData = new FormData();
formData.append('taxonomy_name', taxonomyName);
formData.append('taxonomy_export_id', taxonomyExportId);
formData.append('taxonomy_description', taxonomyDescription);
formData.append('file', file);
const { data } = await getAuthenticatedHttpClient().post(
getTaxonomyImportNewApiUrl(),
formData,
);
return camelCaseObject(data);
}
/**
* Build the mutation to import tags to an existing taxonomy
*/
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);
try {
const { data } = await getAuthenticatedHttpClient().put(
getTagsImportApiUrl(taxonomyId),
formData,
);
return camelCaseObject(data);
} catch (/** @type {any} */ err) {
throw new Error(err.response?.data || err.message);
}
},
onSuccess: (data, variables) => {
queryClient.invalidateQueries({
queryKey: ['tagList', variables.taxonomyId],
});
queryClient.setQueryData(['taxonomyDetail', variables.taxonomyId], data);
},
});
};
/**
* Plan import tags to an existing taxonomy, overwriting existing tags
* @param {number} taxonomyId
* @param {File} file
* @returns {Promise<string>}
*/
export async function planImportTags(taxonomyId, file) {
const formData = new FormData();
formData.append('file', file);
try {
const { data } = await getAuthenticatedHttpClient().put(
getTagsPlanImportApiUrl(taxonomyId),
formData,
);
return data.plan;
} catch (/** @type {any} */ err) {
throw new Error(err.response?.data?.error || err.message);
}
}

View File

@@ -1,88 +0,0 @@
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { renderHook } from '@testing-library/react-hooks';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import MockAdapter from 'axios-mock-adapter';
import { taxonomyImportMock } from '../__mocks__';
import {
getTaxonomyImportNewApiUrl,
getTagsImportApiUrl,
getTagsPlanImportApiUrl,
importNewTaxonomy,
planImportTags,
useImportTags,
} from './api';
let axiosMock;
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
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_export_id', 'Taxonomy description');
expect(axiosMock.history.post[0].url).toEqual(getTaxonomyImportNewApiUrl());
expect(result).toEqual(taxonomyImportMock);
});
it('should call import tags', async () => {
const taxonomy = { id: 1, name: 'taxonomy name' };
axiosMock.onPut(getTagsImportApiUrl(1)).reply(200, taxonomy);
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const mockSetQueryData = jest.spyOn(queryClient, 'setQueryData');
const { result } = renderHook(() => useImportTags(), { wrapper });
await result.current.mutateAsync({ taxonomyId: 1 });
expect(axiosMock.history.put[0].url).toEqual(getTagsImportApiUrl(1));
expect(mockInvalidateQueries).toHaveBeenCalledWith({
queryKey: ['tagList', 1],
});
expect(mockSetQueryData).toHaveBeenCalledWith(['taxonomyDetail', 1], taxonomy);
});
it('should call plan import tags', async () => {
axiosMock.onPut(getTagsPlanImportApiUrl(1)).reply(200, { plan: 'plan' });
await planImportTags(1);
expect(axiosMock.history.put[0].url).toEqual(getTagsPlanImportApiUrl(1));
});
it('should handle errors in plan import tags', async () => {
axiosMock.onPut(getTagsPlanImportApiUrl(1)).reply(400, { error: 'test error' });
expect(planImportTags(1)).rejects.toEqual(Error('test error'));
expect(axiosMock.history.put[0].url).toEqual(getTagsPlanImportApiUrl(1));
});
});

View File

@@ -1,3 +1,3 @@
// @ts-check
export { importTaxonomy } from './data/utils';
export { importTaxonomy } from './utils';
export { default as ImportTagsWizard } from './ImportTagsWizard';

View File

@@ -1,6 +1,5 @@
// @ts-check
import messages from '../messages';
import { importNewTaxonomy } from './api';
import messages from './messages';
/*
* This function get a file from the user. It does this by creating a
@@ -38,7 +37,12 @@ const selectFile = async () => new Promise((resolve) => {
});
/* istanbul ignore next */
export const importTaxonomy = async (intl) => { // eslint-disable-line import/prefer-default-export
/**
* @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.
*/
export const importTaxonomy = async (intl, importMutation) => { // eslint-disable-line import/prefer-default-export
/*
* This function is a temporary "Barebones" implementation of the import
* functionality with `prompt` and `alert`. It is intended to be replaced
@@ -92,27 +96,30 @@ export const importTaxonomy = async (intl) => { // eslint-disable-line import/pr
return;
}
const taxonomyName = getTaxonomyName();
if (taxonomyName == null) {
const name = getTaxonomyName();
if (name == null) {
return;
}
const taxonomyExportId = getTaxonomyExportId();
if (taxonomyExportId == null) {
const exportId = getTaxonomyExportId();
if (exportId == null) {
return;
}
const taxonomyDescription = getTaxonomyDescription();
if (taxonomyDescription == null) {
const description = getTaxonomyDescription();
if (description == null) {
return;
}
importNewTaxonomy(taxonomyName, taxonomyExportId, taxonomyDescription, file)
.then(() => {
alert(intl.formatMessage(messages.importTaxonomySuccess));
})
.catch((error) => {
alert(intl.formatMessage(messages.importTaxonomyError));
console.error(error.response);
});
importMutation.mutateAsync({
name,
exportId,
description,
file,
}).then(() => {
alert(intl.formatMessage(messages.importTaxonomySuccess));
}).catch((error) => {
alert(intl.formatMessage(messages.importTaxonomyError));
console.error(error.response);
});
};

View File

@@ -20,7 +20,7 @@ import PropTypes from 'prop-types';
import { useOrganizationListData } from '../../generic/data/apiHooks';
import { TaxonomyContext } from '../common/context';
import { useTaxonomyDetailDataResponse } from '../data/apiHooks';
import { useTaxonomyDetails } from '../data/apiHooks';
import { useManageOrgs } from './data/api';
import messages from './messages';
import './ManageOrgsModal.scss';
@@ -83,7 +83,7 @@ const ManageOrgsModal = ({
data: organizationListData,
} = useOrganizationListData();
const taxonomy = useTaxonomyDetailDataResponse(taxonomyId);
const { data: taxonomy } = useTaxonomyDetails(taxonomyId);
const manageOrgMutation = useManageOrgs();

View File

@@ -7,8 +7,7 @@ import Proptypes from 'prop-types';
import { LoadingSpinner } from '../../generic/Loading';
import messages from './messages';
import { useTagListDataResponse, useTagListDataStatus } from './data/apiHooks';
import { useSubTags } from './data/api';
import { useTagListData, useSubTags } from './data/apiHooks';
const SubTagsExpanded = ({ taxonomyId, parentTagValue }) => {
const subTagsData = useSubTags(taxonomyId, parentTagValue);
@@ -69,8 +68,7 @@ const TagListTable = ({ taxonomyId }) => {
pageIndex: 0,
pageSize: 100,
});
const { isLoading } = useTagListDataStatus(taxonomyId, options);
const tagList = useTagListDataResponse(taxonomyId, options);
const { isLoading, data: tagList } = useTagListData(taxonomyId, options);
const fetchData = (args) => {
if (!isEqual(args, options)) {

View File

@@ -1,52 +0,0 @@
// @ts-check
// TODO: this file needs to be merged into src/taxonomy/data/api.js
// We are creating a mess with so many different /data/[api|types].js files in subfolders.
// There is only one tagging/taxonomy API, and it should be implemented via a single types.mjs and api.js file.
import { useQuery } from '@tanstack/react-query';
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
const getTagListApiUrl = (taxonomyId, page, pageSize) => {
const url = new URL(`api/content_tagging/v1/taxonomies/${taxonomyId}/tags/`, getApiBaseUrl());
url.searchParams.append('page', page + 1);
url.searchParams.append('page_size', pageSize);
return url.href;
};
/**
* @param {number} taxonomyId
* @param {import('./types.mjs').QueryOptions} options
* @returns {import('@tanstack/react-query').UseQueryResult<import('./types.mjs').TagListData>}
*/
export const useTagListData = (taxonomyId, options) => {
const { pageIndex, pageSize } = options;
return useQuery({
queryKey: ['tagList', taxonomyId, pageIndex],
queryFn: async () => {
const { data } = await getAuthenticatedHttpClient().get(getTagListApiUrl(taxonomyId, pageIndex, pageSize));
return camelCaseObject(data);
},
});
};
/**
* Temporary hook to load *all* the subtags of a given tag in a taxonomy.
* Doesn't handle pagination or anything. This is meant to be replaced by
* something more sophisticated later, as we improve the "taxonomy details" page.
* @param {number} taxonomyId
* @param {string} parentTagValue
* @returns {import('@tanstack/react-query').UseQueryResult<import('./types.mjs').TagListData>}
*/
export const useSubTags = (taxonomyId, parentTagValue) => useQuery({
queryKey: ['subtagsList', taxonomyId, parentTagValue],
queryFn: async () => {
const url = new URL(`api/content_tagging/v1/taxonomies/${taxonomyId}/tags/`, getApiBaseUrl());
url.searchParams.set('full_depth_threshold', '10000'); // Load as deeply as we can
url.searchParams.set('parent_tag', parentTagValue);
const response = await getAuthenticatedHttpClient().get(url.href);
return camelCaseObject(response.data);
},
});

View File

@@ -1,27 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import {
useTagListData,
} from './api';
const mockHttpClient = {
get: jest.fn(),
};
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn(),
}));
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(() => mockHttpClient),
}));
describe('useTagListData', () => {
it('should call useQuery with the correct parameters', () => {
useTagListData('1', { pageIndex: 3 });
expect(useQuery).toHaveBeenCalledWith({
queryKey: ['tagList', '1', 3],
queryFn: expect.any(Function),
});
});
});

View File

@@ -0,0 +1,41 @@
// @ts-check
// TODO: this file needs to be merged into src/taxonomy/data/apiHooks.js
import { useQuery } from '@tanstack/react-query';
import { camelCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { apiUrls } from '../../data/api';
import { taxonomyQueryKeys } from '../../data/apiHooks';
/**
* @param {number} taxonomyId
* @param {import('./types.mjs').QueryOptions} options
* @returns {import('@tanstack/react-query').UseQueryResult<import('./types.mjs').TagListData>}
*/
export const useTagListData = (taxonomyId, options) => {
const { pageIndex, pageSize } = options;
return useQuery({
queryKey: taxonomyQueryKeys.taxonomyTagListPage(taxonomyId, pageIndex, pageSize),
queryFn: async () => {
const { data } = await getAuthenticatedHttpClient().get(apiUrls.tagList(taxonomyId, pageIndex, pageSize));
return camelCaseObject(data);
},
});
};
/**
* Temporary hook to load *all* the subtags of a given tag in a taxonomy.
* Doesn't handle pagination or anything. This is meant to be replaced by
* something more sophisticated later, as we improve the "taxonomy details" page.
* @param {number} taxonomyId
* @param {string} parentTagValue
* @returns {import('@tanstack/react-query').UseQueryResult<import('./types.mjs').TagListData>}
*/
export const useSubTags = (taxonomyId, parentTagValue) => useQuery({
queryKey: taxonomyQueryKeys.taxonomyTagSubtagsList(taxonomyId, parentTagValue),
queryFn: async () => {
const response = await getAuthenticatedHttpClient().get(apiUrls.allSubtagsOf(taxonomyId, parentTagValue));
return camelCaseObject(response.data);
},
});

View File

@@ -1,41 +0,0 @@
// @ts-check
import {
useTagListData,
} from './api';
/* eslint-disable max-len */
/**
* @param {number} taxonomyId
* @param {import("./types.mjs").QueryOptions} options
* @returns {Pick<import('@tanstack/react-query').UseQueryResult, "error" | "isError" | "isFetched" | "isLoading" | "isSuccess" >}
*/ /* eslint-enable max-len */
export const useTagListDataStatus = (taxonomyId, options) => {
const {
error,
isError,
isFetched,
isLoading,
isSuccess,
} = useTagListData(taxonomyId, options);
return {
error,
isError,
isFetched,
isLoading,
isSuccess,
};
};
/**
* @param {number} taxonomyId
* @param {import("./types.mjs").QueryOptions} options
* @returns {import("./types.mjs").TagListData | undefined}
*/
export const useTagListDataResponse = (taxonomyId, options) => {
const { isSuccess, data } = useTagListData(taxonomyId, options);
if (isSuccess) {
return data;
}
return undefined;
};

View File

@@ -1,45 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import {
useTagListDataStatus,
useTagListDataResponse,
} from './apiHooks';
jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn(),
}));
describe('useTagListDataStatus', () => {
it('should return status values', () => {
const status = {
error: undefined,
isError: false,
isFetched: true,
isLoading: true,
isSuccess: true,
};
useQuery.mockReturnValueOnce(status);
const result = useTagListDataStatus(0, {});
expect(result).toEqual(status);
});
});
describe('useTagListDataResponse', () => {
it('should return data when status is success', () => {
useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' });
const result = useTagListDataResponse(0, {});
expect(result).toEqual('data');
});
it('should return undefined when status is not success', () => {
useQuery.mockReturnValueOnce({ isSuccess: false });
const result = useTagListDataResponse(0, {});
expect(result).toBeUndefined();
});
});

View File

@@ -17,7 +17,7 @@ import taxonomyMessages from '../messages';
import { TagListTable } from '../tag-list';
import { TaxonomyMenu } from '../taxonomy-menu';
import TaxonomyDetailSideCard from './TaxonomyDetailSideCard';
import { useTaxonomyDetailDataResponse, useTaxonomyDetailDataStatus } from '../data/apiHooks';
import { useTaxonomyDetails } from '../data/apiHooks';
import SystemDefinedBadge from '../system-defined-badge';
const TaxonomyDetailPage = () => {
@@ -25,8 +25,11 @@ const TaxonomyDetailPage = () => {
const { taxonomyId: taxonomyIdString } = useParams();
const taxonomyId = Number(taxonomyIdString);
const taxonomy = useTaxonomyDetailDataResponse(taxonomyId);
const { isError, isFetched } = useTaxonomyDetailDataStatus(taxonomyId);
const {
data: taxonomy,
isError,
isFetched,
} = useTaxonomyDetails(taxonomyId);
if (!isFetched) {
return (

View File

@@ -6,7 +6,7 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { fireEvent, render } from '@testing-library/react';
import { getTaxonomyApiUrl } from '../data/api';
import { apiUrls } from '../data/api';
import initializeStore from '../../store';
import TaxonomyDetailPage from './TaxonomyDetailPage';
@@ -64,7 +64,7 @@ describe('<TaxonomyDetailPage />', () => {
it('shows the spinner before the query is complete', () => {
// Use unresolved promise to keep the Loading visible
axiosMock.onGet(getTaxonomyApiUrl(1)).reply(() => new Promise());
axiosMock.onGet(apiUrls.taxonomy(1)).reply(() => new Promise());
const { getByRole } = render(<RootWrapper />);
const spinner = getByRole('status');
expect(spinner.textContent).toEqual('Loading...');
@@ -73,7 +73,7 @@ describe('<TaxonomyDetailPage />', () => {
it('shows the connector error component if not taxonomy returned', async () => {
// Use empty response to trigger the error. Returning an error do not
// work because the query will retry.
axiosMock.onGet(getTaxonomyApiUrl(1)).reply(200);
axiosMock.onGet(apiUrls.taxonomy(1)).reply(200);
const { findByTestId } = render(<RootWrapper />);
@@ -81,7 +81,7 @@ describe('<TaxonomyDetailPage />', () => {
});
it('should render page and page title correctly', async () => {
await axiosMock.onGet(getTaxonomyApiUrl(1)).replyOnce(200, {
await axiosMock.onGet(apiUrls.taxonomy(1)).replyOnce(200, {
id: 1,
name: 'Test taxonomy',
description: 'This is a description',
@@ -109,7 +109,7 @@ describe('<TaxonomyDetailPage />', () => {
});
it('should show system defined badge', async () => {
axiosMock.onGet(getTaxonomyApiUrl(1)).replyOnce(200, {
axiosMock.onGet(apiUrls.taxonomy(1)).replyOnce(200, {
id: 1,
name: 'Test taxonomy',
description: 'This is a description',
@@ -125,7 +125,7 @@ describe('<TaxonomyDetailPage />', () => {
});
it('should not show system defined badge', async () => {
axiosMock.onGet(getTaxonomyApiUrl(1)).replyOnce(200, {
axiosMock.onGet(apiUrls.taxonomy(1)).replyOnce(200, {
id: 1,
name: 'Test taxonomy',
description: 'This is a description',