refactor: Convert more Taxonomy code to TypeScript (2) (#1536)
* Converts some files from .js or .mjs to .ts * Refactors some tests to use the new initializeMocks helper * Cleans up and improves some type definitions
This commit is contained in:
@@ -1,14 +1,9 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import initializeStore from '../store';
|
||||
import { initializeMocks, render } from '../testUtils';
|
||||
import { TaxonomyContext } from './common/context';
|
||||
import TaxonomyLayout from './TaxonomyLayout';
|
||||
import { TaxonomyLayout } from './TaxonomyLayout';
|
||||
|
||||
let store;
|
||||
const toastMessage = 'Hello, this is a toast!';
|
||||
const alertErrorTitle = 'Error title';
|
||||
const alertErrorDescription = 'Error description';
|
||||
@@ -20,14 +15,14 @@ const MockChildComponent = () => {
|
||||
<div data-testid="mock-content">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setToastMessage(toastMessage)}
|
||||
onClick={() => setToastMessage!(toastMessage)}
|
||||
data-testid="taxonomy-show-toast"
|
||||
>
|
||||
Show Toast
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAlertProps({ title: alertErrorTitle, description: alertErrorDescription })}
|
||||
onClick={() => setAlertProps!({ title: alertErrorTitle, description: alertErrorDescription })}
|
||||
data-testid="taxonomy-show-alert"
|
||||
>
|
||||
Show Alert
|
||||
@@ -46,36 +41,20 @@ jest.mock('react-router-dom', () => ({
|
||||
ScrollRestoration: jest.fn(() => <div />),
|
||||
}));
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<TaxonomyLayout />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
describe('<TaxonomyLayout />', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
it('should render page correctly', () => {
|
||||
const { getByTestId } = render(<RootWrapper />);
|
||||
const { getByTestId } = render(<TaxonomyLayout />);
|
||||
expect(getByTestId('mock-header')).toBeInTheDocument();
|
||||
expect(getByTestId('mock-content')).toBeInTheDocument();
|
||||
expect(getByTestId('mock-footer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show toast', () => {
|
||||
const { getByTestId, getByText } = render(<RootWrapper />);
|
||||
const { getByTestId, getByText } = render(<TaxonomyLayout />);
|
||||
const button = getByTestId('taxonomy-show-toast');
|
||||
button.click();
|
||||
expect(getByTestId('taxonomy-toast')).toBeInTheDocument();
|
||||
@@ -88,7 +67,7 @@ describe('<TaxonomyLayout />', () => {
|
||||
getByText,
|
||||
getByRole,
|
||||
queryByTestId,
|
||||
} = render(<RootWrapper />);
|
||||
} = render(<TaxonomyLayout />);
|
||||
|
||||
const button = getByTestId('taxonomy-show-alert');
|
||||
button.click();
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-check
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { StudioFooter } from '@edx/frontend-component-footer';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
@@ -7,15 +6,15 @@ import { Toast } from '@openedx/paragon';
|
||||
|
||||
import AlertMessage from '../generic/alert-message';
|
||||
import Header from '../header';
|
||||
import { TaxonomyContext } from './common/context';
|
||||
import { type AlertProps, TaxonomyContext } from './common/context';
|
||||
import messages from './messages';
|
||||
|
||||
const TaxonomyLayout = () => {
|
||||
export const TaxonomyLayout = () => {
|
||||
const intl = useIntl();
|
||||
// Use `setToastMessage` to show the toast.
|
||||
const [toastMessage, setToastMessage] = useState(/** @type{null|string} */ (null));
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
// Use `setToastMessage` to show the alert.
|
||||
const [alertProps, setAlertProps] = useState(/** @type {null|import('./common/context').AlertProps} */ (null));
|
||||
const [alertProps, setAlertProps] = useState<AlertProps | null>(null);
|
||||
|
||||
const context = useMemo(() => ({
|
||||
toastMessage, setToastMessage, alertProps, setAlertProps,
|
||||
@@ -51,5 +50,3 @@ const TaxonomyLayout = () => {
|
||||
</TaxonomyContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaxonomyLayout;
|
||||
@@ -1,25 +1,15 @@
|
||||
import React from 'react';
|
||||
import { IntlProvider, injectIntl } 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 type MockAdapter from 'axios-mock-adapter';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
initializeMocks,
|
||||
render as baseRender,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
} from '../testUtils';
|
||||
|
||||
import initializeStore from '../store';
|
||||
import { apiUrls } from './data/api';
|
||||
import TaxonomyListPage from './TaxonomyListPage';
|
||||
import { TaxonomyListPage } from './TaxonomyListPage';
|
||||
import { TaxonomyContext } from './common/context';
|
||||
|
||||
let store;
|
||||
let axiosMock;
|
||||
|
||||
const taxonomies = [{
|
||||
id: 1,
|
||||
name: 'Taxonomy',
|
||||
@@ -39,81 +29,61 @@ const organizations = ['Org 1', 'Org 2'];
|
||||
const context = {
|
||||
toastMessage: null,
|
||||
setToastMessage: jest.fn(),
|
||||
alertProps: null,
|
||||
setAlertProps: jest.fn(),
|
||||
};
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TaxonomyContext.Provider value={context}>
|
||||
<TaxonomyListPage intl={injectIntl} />
|
||||
</TaxonomyContext.Provider>
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
const render = (ui: React.ReactElement) => baseRender(ui, {
|
||||
extraWrapper: ({ children }) => (
|
||||
<TaxonomyContext.Provider value={context}> { children } </TaxonomyContext.Provider>
|
||||
),
|
||||
});
|
||||
let axiosMock: MockAdapter;
|
||||
|
||||
describe('<TaxonomyListPage />', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
const mocks = initializeMocks();
|
||||
axiosMock = mocks.axiosMock;
|
||||
axiosMock.onGet(organizationsListUrl).reply(200, organizations);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render page and page title correctly', () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
const { getByText } = render(<TaxonomyListPage />);
|
||||
expect(getByText('Taxonomies')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the spinner before the query is complete', async () => {
|
||||
// Simulate an API request that times out:
|
||||
axiosMock.onGet(listTaxonomiesUrl).reply(new Promise(() => {}));
|
||||
await act(async () => {
|
||||
const { getByRole } = render(<RootWrapper />);
|
||||
const spinner = getByRole('status');
|
||||
expect(spinner.textContent).toEqual('Loading');
|
||||
});
|
||||
axiosMock.onGet(listTaxonomiesUrl).reply(200, new Promise(() => {}));
|
||||
const { getByRole } = render(<TaxonomyListPage />);
|
||||
const spinner = getByRole('status');
|
||||
expect(spinner.textContent).toEqual('Loading');
|
||||
});
|
||||
|
||||
it('shows the data table after the query is complete', async () => {
|
||||
axiosMock.onGet(listTaxonomiesUrl).reply(200, { results: taxonomies, canAddTaxonomy: false });
|
||||
await act(async () => {
|
||||
const { getByTestId, queryByText } = render(<RootWrapper />);
|
||||
await waitFor(() => { expect(queryByText('Loading')).toEqual(null); });
|
||||
expect(getByTestId('taxonomy-card-1')).toBeInTheDocument();
|
||||
});
|
||||
const { getByTestId, queryByText } = render(<TaxonomyListPage />);
|
||||
await waitFor(() => { expect(queryByText('Loading')).toEqual(null); });
|
||||
expect(getByTestId('taxonomy-card-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(['CSV', 'JSON'])('downloads the taxonomy template %s', async (fileFormat) => {
|
||||
it.each(['csv', 'json'] as const)('downloads the taxonomy template %s', async (fileFormat) => {
|
||||
axiosMock.onGet(listTaxonomiesUrl).reply(200, { results: taxonomies, canAddTaxonomy: false });
|
||||
const { findByRole, queryByText } = render(<RootWrapper />);
|
||||
const { findByRole, queryByText } = render(<TaxonomyListPage />);
|
||||
// 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` });
|
||||
const templateButton = await findByRole('link', { name: `${fileFormat.toUpperCase()} template` });
|
||||
fireEvent.click(templateButton);
|
||||
|
||||
expect(templateButton.href).toBe(apiUrls.taxonomyTemplate(fileFormat.toLowerCase()));
|
||||
expect((templateButton as HTMLAnchorElement).href).toBe(apiUrls.taxonomyTemplate(fileFormat));
|
||||
});
|
||||
|
||||
it('disables the import taxonomy button if not permitted', async () => {
|
||||
axiosMock.onGet(listTaxonomiesUrl).reply(200, { results: [], canAddTaxonomy: false });
|
||||
|
||||
const { queryByText, getByRole } = render(<RootWrapper />);
|
||||
const { queryByText, getByRole } = render(<TaxonomyListPage />);
|
||||
// Wait until data has been loaded and rendered:
|
||||
await waitFor(() => { expect(queryByText('Loading')).toEqual(null); });
|
||||
const importButton = getByRole('button', { name: 'Import' });
|
||||
@@ -123,7 +93,7 @@ describe('<TaxonomyListPage />', () => {
|
||||
it('opens the import dialog modal when the import button is clicked', async () => {
|
||||
axiosMock.onGet(listTaxonomiesUrl).reply(200, { results: [], canAddTaxonomy: true });
|
||||
|
||||
const { getByRole, getByText } = render(<RootWrapper />);
|
||||
const { getByRole, getByText } = render(<TaxonomyListPage />);
|
||||
const importButton = getByRole('button', { name: 'Import' });
|
||||
// Once the API response is received and rendered, the Import button should be enabled:
|
||||
await waitFor(() => { expect(importButton).not.toBeDisabled(); });
|
||||
@@ -152,7 +122,7 @@ describe('<TaxonomyListPage />', () => {
|
||||
getByRole,
|
||||
getAllByText,
|
||||
queryByText,
|
||||
} = render(<RootWrapper />);
|
||||
} = render(<TaxonomyListPage />);
|
||||
// Wait until data has been loaded and rendered:
|
||||
await waitFor(() => { expect(queryByText('Loading')).toEqual(null); });
|
||||
|
||||
@@ -197,14 +167,19 @@ describe('<TaxonomyListPage />', () => {
|
||||
results: [{ name: 'Org2 Taxonomy C', ...defaults }],
|
||||
});
|
||||
|
||||
const { getByRole, getByText, queryByText } = render(<RootWrapper />);
|
||||
const {
|
||||
getByRole,
|
||||
getByText,
|
||||
queryByText,
|
||||
findByRole,
|
||||
} = render(<TaxonomyListPage />);
|
||||
|
||||
// Open the taxonomies org filter select menu
|
||||
const taxonomiesFilterSelectMenu = await getByRole('button', { name: 'All taxonomies' });
|
||||
fireEvent.click(taxonomiesFilterSelectMenu);
|
||||
|
||||
// Check that the 'Unassigned' option is correctly called
|
||||
fireEvent.click(getByRole('link', { name: 'Unassigned' }));
|
||||
fireEvent.click(await findByRole('link', { name: 'Unassigned' }));
|
||||
await waitFor(() => {
|
||||
expect(getByText('Unassigned Taxonomy A')).toBeInTheDocument();
|
||||
});
|
||||
@@ -1,6 +1,4 @@
|
||||
// @ts-check
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Button,
|
||||
CardView,
|
||||
@@ -31,7 +29,7 @@ import { ImportTagsWizard } from './import-tags';
|
||||
import messages from './messages';
|
||||
import TaxonomyCard from './taxonomy-card';
|
||||
|
||||
const TaxonomyListHeaderButtons = ({ canAddTaxonomy }) => {
|
||||
const TaxonomyListHeaderButtons = (props: { canAddTaxonomy: boolean }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [isImportModalOpen, importModalOpen, importModalClose] = useToggle(false);
|
||||
@@ -80,7 +78,7 @@ const TaxonomyListHeaderButtons = ({ canAddTaxonomy }) => {
|
||||
iconBefore={Add}
|
||||
onClick={importModalOpen}
|
||||
data-testid="taxonomy-import-button"
|
||||
disabled={!canAddTaxonomy}
|
||||
disabled={!props.canAddTaxonomy}
|
||||
>
|
||||
{intl.formatMessage(messages.importButtonLabel)}
|
||||
</Button>
|
||||
@@ -93,6 +91,11 @@ const OrganizationFilterSelector = ({
|
||||
organizationListData,
|
||||
selectedOrgFilter,
|
||||
setSelectedOrgFilter,
|
||||
}: {
|
||||
isOrganizationListLoaded: boolean;
|
||||
organizationListData?: string[];
|
||||
selectedOrgFilter: string;
|
||||
setSelectedOrgFilter: (org: string) => void,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const isOrgSelected = (value) => (value === selectedOrgFilter ? <Check /> : null);
|
||||
@@ -152,9 +155,9 @@ const OrganizationFilterSelector = ({
|
||||
);
|
||||
};
|
||||
|
||||
const TaxonomyListPage = () => {
|
||||
export const TaxonomyListPage = () => {
|
||||
const intl = useIntl();
|
||||
const [selectedOrgFilter, setSelectedOrgFilter] = useState(ALL_TAXONOMIES);
|
||||
const [selectedOrgFilter, setSelectedOrgFilter] = useState<string>(ALL_TAXONOMIES);
|
||||
|
||||
const {
|
||||
data: organizationListData,
|
||||
@@ -242,22 +245,3 @@ const TaxonomyListPage = () => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
TaxonomyListHeaderButtons.propTypes = {
|
||||
canAddTaxonomy: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
OrganizationFilterSelector.propTypes = {
|
||||
isOrganizationListLoaded: PropTypes.bool.isRequired,
|
||||
organizationListData: PropTypes.arrayOf(PropTypes.string),
|
||||
selectedOrgFilter: PropTypes.string.isRequired,
|
||||
setSelectedOrgFilter: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
OrganizationFilterSelector.defaultProps = {
|
||||
organizationListData: null,
|
||||
};
|
||||
|
||||
TaxonomyListPage.propTypes = {};
|
||||
|
||||
export default TaxonomyListPage;
|
||||
@@ -1,8 +1,4 @@
|
||||
// @ts-check
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { initializeMocks } from '../../testUtils';
|
||||
import { taxonomyListMock } from '../__mocks__';
|
||||
|
||||
import {
|
||||
@@ -13,32 +9,14 @@ import {
|
||||
deleteTaxonomy,
|
||||
} from './api';
|
||||
|
||||
let axiosMock;
|
||||
|
||||
describe('taxonomy api calls', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
'All taxonomies',
|
||||
'Unassigned',
|
||||
'testOrg',
|
||||
])('should get taxonomy list data for \'%s\' org filter', async (org) => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
axiosMock.onGet(apiUrls.taxonomyList(org)).reply(200, taxonomyListMock);
|
||||
const result = await getTaxonomyListData(org);
|
||||
|
||||
@@ -47,6 +25,7 @@ describe('taxonomy api calls', () => {
|
||||
});
|
||||
|
||||
it('should delete a taxonomy', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
const taxonomyId = 123;
|
||||
axiosMock.onDelete(apiUrls.taxonomy(taxonomyId)).reply(200);
|
||||
await deleteTaxonomy(taxonomyId);
|
||||
@@ -55,6 +34,7 @@ describe('taxonomy api calls', () => {
|
||||
});
|
||||
|
||||
it('should call get taxonomy', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
axiosMock.onGet(apiUrls.taxonomy(1)).reply(200);
|
||||
await getTaxonomy(1);
|
||||
|
||||
@@ -62,6 +42,7 @@ describe('taxonomy api calls', () => {
|
||||
});
|
||||
|
||||
it('Export should set window.location.href correctly', () => {
|
||||
initializeMocks();
|
||||
const origLocation = window.location;
|
||||
// @ts-ignore
|
||||
delete window.location;
|
||||
@@ -1,15 +1,15 @@
|
||||
// @ts-check
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import type { TaxonomyData, TaxonomyListData } from './types';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
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
|
||||
* @param path The subpath within the taxonomies "v1" REST API namespace
|
||||
* @param searchParams Query parameters to include
|
||||
*/
|
||||
const makeUrl = (path, searchParams) => {
|
||||
const makeUrl = (path: string, searchParams?: Record<string, string | number>): string => {
|
||||
const url = new URL(path, getTaxonomiesV1Endpoint());
|
||||
if (searchParams) {
|
||||
Object.entries(searchParams).forEach(([k, v]) => url.searchParams.append(k, String(v)));
|
||||
@@ -20,14 +20,13 @@ const makeUrl = (path, searchParams) => {
|
||||
export const ALL_TAXONOMIES = '__all';
|
||||
export const UNASSIGNED = '__unassigned';
|
||||
|
||||
/** @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
|
||||
* @param org Optionally, Filter the list to only show taxonomies assigned to this org
|
||||
*/
|
||||
taxonomyList(org) {
|
||||
const params = {};
|
||||
taxonomyList(org?: string) {
|
||||
const params: Record<string, string> = {};
|
||||
if (org !== undefined) {
|
||||
if (org === UNASSIGNED) {
|
||||
params.unassigned = 'true';
|
||||
@@ -39,87 +38,74 @@ export const apiUrls = {
|
||||
},
|
||||
/**
|
||||
* 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
|
||||
* @param taxonomyId The ID of the taxonomy
|
||||
* @param format Which format to use for the export
|
||||
*/
|
||||
exportTaxonomy: (taxonomyId, format) => makeUrl(`${taxonomyId}/export/`, { output_format: format, download: 1 }),
|
||||
exportTaxonomy: (taxonomyId: number, format: 'json' | 'csv') => (
|
||||
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
|
||||
* @param 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}/`),
|
||||
taxonomyTemplate: (format: 'json' | 'csv') => makeUrl(`import/template.${format}`),
|
||||
/** Get the URL for a Taxonomy */
|
||||
taxonomy: (taxonomyId: number) => 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
|
||||
* @param pageIndex Zero-indexed page number
|
||||
* @param pageSize How many tags per page to load
|
||||
*/
|
||||
tagList: (taxonomyId, pageIndex, pageSize) => makeUrl(`${taxonomyId}/tags/`, {
|
||||
tagList: (taxonomyId: number, pageIndex: number, pageSize: number) => 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/`, {
|
||||
allSubtagsOf: (taxonomyId: number, parentTagValue: string) => 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
|
||||
*/
|
||||
/** URL to import tags into an existing taxonomy */
|
||||
tagsImport: (taxonomyId) => makeUrl(`${taxonomyId}/tags/import/`),
|
||||
/**
|
||||
* @param {number} taxonomyId
|
||||
*/
|
||||
tagsPlanImport: (taxonomyId) => makeUrl(`${taxonomyId}/tags/import/plan/`),
|
||||
};
|
||||
/** URL to plan (preview what would happen) a taxonomy import */
|
||||
tagsPlanImport: (taxonomyId: number) => makeUrl(`${taxonomyId}/tags/import/plan/`),
|
||||
} satisfies Record<string, (...args: any[]) => string>;
|
||||
|
||||
/**
|
||||
* Get list of taxonomies.
|
||||
* @param {string} [org] Filter the list to only show taxonomies assigned to this org
|
||||
* @returns {Promise<import("./types.js").TaxonomyListData>}
|
||||
* @param org Optionally, filter the list to only show taxonomies assigned to this org
|
||||
*/
|
||||
export async function getTaxonomyListData(org) {
|
||||
export async function getTaxonomyListData(org?: string): Promise<TaxonomyListData> {
|
||||
const { data } = await getAuthenticatedHttpClient().get(apiUrls.taxonomyList(org));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a Taxonomy
|
||||
* @param {number} taxonomyId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function deleteTaxonomy(taxonomyId) {
|
||||
export async function deleteTaxonomy(taxonomyId: number): Promise<void> {
|
||||
await getAuthenticatedHttpClient().delete(apiUrls.taxonomy(taxonomyId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata about a Taxonomy
|
||||
* @param {number} taxonomyId The ID of the taxonomy to get
|
||||
* @returns {Promise<import("./types.js").TaxonomyData>}
|
||||
* @param taxonomyId The ID of the taxonomy to get
|
||||
*/
|
||||
export async function getTaxonomy(taxonomyId) {
|
||||
export async function getTaxonomy(taxonomyId: number): Promise<TaxonomyData> {
|
||||
const { data } = await getAuthenticatedHttpClient().get(apiUrls.taxonomy(taxonomyId));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the file of the exported taxonomy
|
||||
* @param {number} taxonomyId The ID of the taxonomy
|
||||
* @param {'json'|'csv'} format Which format to use for the export file.
|
||||
* @returns {void}
|
||||
* @param taxonomyId The ID of the taxonomy
|
||||
* @param format Which format to use for the export file.
|
||||
*/
|
||||
export function getTaxonomyExportFile(taxonomyId, format) {
|
||||
export function getTaxonomyExportFile(taxonomyId: number, format: 'json' | 'csv'): void {
|
||||
window.location.href = apiUrls.exportTaxonomy(taxonomyId, format);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* This is a file used especially in this `taxonomy` module.
|
||||
*
|
||||
@@ -16,6 +15,7 @@ import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { apiUrls, ALL_TAXONOMIES } from './api';
|
||||
import * as api from './api';
|
||||
import type { QueryOptions, TagListData } from './types';
|
||||
|
||||
// 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
|
||||
@@ -24,46 +24,46 @@ 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)
|
||||
* @param org Which org we fetched the taxonomy list for (optional)
|
||||
*/
|
||||
taxonomyList: (org) => [
|
||||
taxonomyList: (org?: string) => [
|
||||
...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
|
||||
* @param taxonomyId ID of the taxonomy
|
||||
*/
|
||||
taxonomy: (taxonomyId) => [...taxonomyQueryKeys.all, 'taxonomy', taxonomyId],
|
||||
taxonomy: (taxonomyId: number) => [...taxonomyQueryKeys.all, 'taxonomy', taxonomyId],
|
||||
/**
|
||||
* @param {number} taxonomyId ID of the taxonomy
|
||||
* @param taxonomyId ID of the taxonomy
|
||||
*/
|
||||
taxonomyMetadata: (taxonomyId) => [...taxonomyQueryKeys.taxonomy(taxonomyId), 'metadata'],
|
||||
taxonomyMetadata: (taxonomyId: number) => [...taxonomyQueryKeys.taxonomy(taxonomyId), 'metadata'],
|
||||
/**
|
||||
* @param {number} taxonomyId ID of the taxonomy
|
||||
* @param taxonomyId ID of the taxonomy
|
||||
*/
|
||||
taxonomyTagList: (taxonomyId) => [...taxonomyQueryKeys.taxonomy(taxonomyId), 'tags'],
|
||||
taxonomyTagList: (taxonomyId: number) => [...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
|
||||
* @param taxonomyId ID of the taxonomy
|
||||
* @param pageIndex Which page of tags to load (zero-based)
|
||||
* @param pageSize
|
||||
*/
|
||||
taxonomyTagListPage: (taxonomyId, pageIndex, pageSize) => [
|
||||
taxonomyTagListPage: (taxonomyId: number, pageIndex: number, pageSize: number) => [
|
||||
...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
|
||||
* @param taxonomyId ID of the taxonomy
|
||||
* @param parentTagValue
|
||||
*/
|
||||
taxonomyTagSubtagsList: (taxonomyId, parentTagValue) => [
|
||||
taxonomyTagSubtagsList: (taxonomyId: number, parentTagValue: string) => [
|
||||
...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
|
||||
* @param taxonomyId ID of the taxonomy
|
||||
* @param fileId Some string to uniquely identify the file we want to upload
|
||||
*/
|
||||
importPlan: (taxonomyId, fileId) => [...taxonomyQueryKeys.all, 'importPlan', taxonomyId, fileId],
|
||||
};
|
||||
importPlan: (taxonomyId: number, fileId: string) => [...taxonomyQueryKeys.all, 'importPlan', taxonomyId, fileId],
|
||||
} satisfies Record<string, (string | number)[] | ((...args: any[]) => (string | number)[])>;
|
||||
|
||||
/**
|
||||
* Builds the query to get the taxonomy list
|
||||
@@ -83,8 +83,7 @@ export const useTaxonomyList = (org) => (
|
||||
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),
|
||||
mutationFn: async ({ pk }: { pk: number }) => api.deleteTaxonomy(pk),
|
||||
onSettled: (_d, _e, args) => {
|
||||
queryClient.invalidateQueries({ queryKey: taxonomyQueryKeys.taxonomyList() });
|
||||
queryClient.removeQueries({ queryKey: taxonomyQueryKeys.taxonomy(args.pk) });
|
||||
@@ -93,10 +92,8 @@ export const useDeleteTaxonomy = () => {
|
||||
return mutateAsync;
|
||||
};
|
||||
|
||||
/** Builds the query to get the taxonomy detail
|
||||
* @param {number} taxonomyId
|
||||
*/
|
||||
export const useTaxonomyDetails = (taxonomyId) => useQuery({
|
||||
/** Builds the query to get the taxonomy detail */
|
||||
export const useTaxonomyDetails = (taxonomyId: number) => useQuery({
|
||||
queryKey: taxonomyQueryKeys.taxonomyMetadata(taxonomyId),
|
||||
queryFn: () => api.getTaxonomy(taxonomyId),
|
||||
});
|
||||
@@ -107,20 +104,9 @@ export const useTaxonomyDetails = (taxonomyId) => useQuery({
|
||||
export const useImportNewTaxonomy = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
/**
|
||||
* @type {import("@tanstack/react-query").MutateFunction<
|
||||
* import("./types.js").TaxonomyData,
|
||||
* any,
|
||||
* {
|
||||
* name: string,
|
||||
* description: string,
|
||||
* file: File,
|
||||
* }
|
||||
* >}
|
||||
*/
|
||||
mutationFn: async ({
|
||||
name, description, file,
|
||||
}) => {
|
||||
}: { name: string, description: string, file: File }) => {
|
||||
const formData = new FormData();
|
||||
formData.append('taxonomy_name', name);
|
||||
formData.append('taxonomy_description', description);
|
||||
@@ -145,25 +131,15 @@ export const useImportNewTaxonomy = () => {
|
||||
export const useImportTags = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
/**
|
||||
* @type {import("@tanstack/react-query").MutateFunction<
|
||||
* import("./types.js").TaxonomyData,
|
||||
* any,
|
||||
* {
|
||||
* taxonomyId: number,
|
||||
* file: File,
|
||||
* }
|
||||
* >}
|
||||
*/
|
||||
mutationFn: async ({ taxonomyId, file }) => {
|
||||
mutationFn: async ({ taxonomyId, file }: { taxonomyId: number, file: 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);
|
||||
} catch (err) {
|
||||
throw new Error((err as any).response?.data?.error || (err as any).message);
|
||||
}
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
@@ -178,15 +154,12 @@ export const useImportTags = () => {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param taxonomyId The ID of the taxonomy whose tags we're updating.
|
||||
* @param file The file that we want to import
|
||||
*/
|
||||
export const useImportPlan = (taxonomyId, file) => useQuery({
|
||||
export const useImportPlan = (taxonomyId: number, file: File | null) => useQuery({
|
||||
queryKey: taxonomyQueryKeys.importPlan(taxonomyId, file ? `${file.name}${file.lastModified}${file.size}` : ''),
|
||||
/**
|
||||
* @type {import("@tanstack/react-query").QueryFunction<string|null>}
|
||||
*/
|
||||
queryFn: async () => {
|
||||
queryFn: async (): Promise<string | null> => {
|
||||
if (!taxonomyId || file === null) {
|
||||
return null;
|
||||
}
|
||||
@@ -195,26 +168,24 @@ export const useImportPlan = (taxonomyId, file) => useQuery({
|
||||
|
||||
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);
|
||||
return data.plan as string;
|
||||
} catch (err) {
|
||||
throw new Error((err as any).response?.data?.error || (err as any).message);
|
||||
}
|
||||
},
|
||||
retry: false, // If there's an error, it's probably a real problem with the file. Don't try again several times!
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {number} taxonomyId
|
||||
* @param {import('./types.js').QueryOptions} options
|
||||
* @returns {import('@tanstack/react-query').UseQueryResult<import('./types.js').TagListData>}
|
||||
* Use the list of tags in a taxonomy.
|
||||
*/
|
||||
export const useTagListData = (taxonomyId, options) => {
|
||||
export const useTagListData = (taxonomyId: number, options: QueryOptions) => {
|
||||
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);
|
||||
return camelCaseObject(data) as TagListData;
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -223,14 +194,11 @@ export const useTagListData = (taxonomyId, options) => {
|
||||
* 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.js').TagListData>}
|
||||
*/
|
||||
export const useSubTags = (taxonomyId, parentTagValue) => useQuery({
|
||||
export const useSubTags = (taxonomyId: number, parentTagValue: string) => useQuery({
|
||||
queryKey: taxonomyQueryKeys.taxonomyTagSubtagsList(taxonomyId, parentTagValue),
|
||||
queryFn: async () => {
|
||||
const response = await getAuthenticatedHttpClient().get(apiUrls.allSubtagsOf(taxonomyId, parentTagValue));
|
||||
return camelCaseObject(response.data);
|
||||
return camelCaseObject(response.data) as TagListData;
|
||||
},
|
||||
});
|
||||
@@ -566,4 +566,4 @@ ImportTagsWizard.propTypes = {
|
||||
reimport: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default ImportTagsWizard;
|
||||
export { ImportTagsWizard };
|
||||
|
||||
@@ -17,7 +17,7 @@ import PropTypes from 'prop-types';
|
||||
import initializeStore from '../../store';
|
||||
import { getTaxonomyExportFile } from '../data/api';
|
||||
import { TaxonomyContext } from '../common/context';
|
||||
import ImportTagsWizard from './ImportTagsWizard';
|
||||
import { ImportTagsWizard } from './ImportTagsWizard';
|
||||
|
||||
let store;
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// @ts-check
|
||||
export { default as ImportTagsWizard } from './ImportTagsWizard';
|
||||
1
src/taxonomy/import-tags/index.ts
Normal file
1
src/taxonomy/import-tags/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ImportTagsWizard } from './ImportTagsWizard';
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-check
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -1,3 +0,0 @@
|
||||
export { default as TaxonomyListPage } from './TaxonomyListPage';
|
||||
export { default as TaxonomyLayout } from './TaxonomyLayout';
|
||||
export { TaxonomyDetailPage } from './taxonomy-detail';
|
||||
3
src/taxonomy/index.ts
Normal file
3
src/taxonomy/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { TaxonomyListPage } from './TaxonomyListPage';
|
||||
export { TaxonomyLayout } from './TaxonomyLayout';
|
||||
export { TaxonomyDetailPage } from './taxonomy-detail';
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-check
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -1,2 +1 @@
|
||||
// @ts-check
|
||||
export { default as TaxonomyDetailPage } from './TaxonomyDetailPage';
|
||||
@@ -1,4 +1,3 @@
|
||||
// ts-check
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -1,2 +1 @@
|
||||
// @ts-check
|
||||
export { default as TaxonomyMenu } from './TaxonomyMenu';
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-check
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
Reference in New Issue
Block a user