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:
@@ -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) {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 = () => (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
206
src/taxonomy/data/apiHooks.js
Normal file
206
src/taxonomy/data/apiHooks.js
Normal 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!
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { default as taxonomyImportMock } from './taxonomyImportMock'; // eslint-disable-line import/prefer-default-export
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export default {
|
||||
name: 'Taxonomy name',
|
||||
exportId: 'taxonomy_export_id',
|
||||
description: 'Taxonomy description',
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,3 @@
|
||||
// @ts-check
|
||||
export { importTaxonomy } from './data/utils';
|
||||
export { importTaxonomy } from './utils';
|
||||
export { default as ImportTagsWizard } from './ImportTagsWizard';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
41
src/taxonomy/tag-list/data/apiHooks.js
Normal file
41
src/taxonomy/tag-list/data/apiHooks.js
Normal 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);
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user