feat: Add filter taxonomies by org (#755)
This implements filtering taxonomies on the taxonomy list page by selecting organization name, all taxonomies, or unassigned taxonomies.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
// @ts-check
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
@@ -9,8 +10,8 @@ export const getCourseRerunUrl = (courseId) => new URL(`/api/contentstore/v1/cou
|
||||
export const getOrganizationsUrl = () => new URL('organizations', getApiBaseUrl()).href;
|
||||
|
||||
/**
|
||||
* Get's organizations data.
|
||||
* @returns {Promise<Object>}
|
||||
* Get's organizations data. Returns list of organization names.
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
export async function getOrganizations() {
|
||||
const { data } = await getAuthenticatedHttpClient().get(
|
||||
@@ -32,7 +33,7 @@ export async function getCourseRerun(courseId) {
|
||||
|
||||
/**
|
||||
* Create or rerun course with data.
|
||||
* @param {object} data
|
||||
* @param {object} courseData
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function createOrRerunCourse(courseData) {
|
||||
|
||||
15
src/generic/data/apiHooks.js
Normal file
15
src/generic/data/apiHooks.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// @ts-check
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getOrganizations } from './api';
|
||||
|
||||
/**
|
||||
* Builds the query to get a list of available organizations
|
||||
*/
|
||||
export const useOrganizationListData = () => (
|
||||
useQuery({
|
||||
queryKey: ['organizationList'],
|
||||
queryFn: () => getOrganizations(),
|
||||
})
|
||||
);
|
||||
|
||||
export default useOrganizationListData;
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useContext } from 'react';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
Button,
|
||||
CardView,
|
||||
@@ -8,9 +9,12 @@ import {
|
||||
OverlayTrigger,
|
||||
Spinner,
|
||||
Tooltip,
|
||||
SelectMenu,
|
||||
MenuItem,
|
||||
} from '@edx/paragon';
|
||||
import {
|
||||
Add,
|
||||
Check,
|
||||
} from '@edx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Helmet } from 'react-helmet';
|
||||
@@ -20,8 +24,12 @@ import messages from './messages';
|
||||
import TaxonomyCard from './taxonomy-card';
|
||||
import { getTaxonomyTemplateApiUrl } from './data/api';
|
||||
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded, useDeleteTaxonomy } from './data/apiHooks';
|
||||
import { useOrganizationListData } from '../generic/data/apiHooks';
|
||||
import { TaxonomyContext } from './common/context';
|
||||
|
||||
const ALL_TAXONOMIES = 'All taxonomies';
|
||||
const UNASSIGNED = 'Unassigned';
|
||||
|
||||
const TaxonomyListHeaderButtons = () => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
@@ -64,10 +72,75 @@ const TaxonomyListHeaderButtons = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const OrganizationFilterSelector = ({
|
||||
isOrganizationListLoaded,
|
||||
organizationListData,
|
||||
selectedOrgFilter,
|
||||
setSelectedOrgFilter,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const isOrgSelected = (value) => (value === selectedOrgFilter ? <Check /> : null);
|
||||
const selectOptions = [
|
||||
<MenuItem
|
||||
key="all-orgs-taxonomies"
|
||||
className="x-small"
|
||||
iconAfter={() => isOrgSelected(ALL_TAXONOMIES)}
|
||||
onClick={() => setSelectedOrgFilter(ALL_TAXONOMIES)}
|
||||
>
|
||||
{ isOrgSelected(ALL_TAXONOMIES)
|
||||
? intl.formatMessage(messages.orgInputSelectDefaultValue)
|
||||
: intl.formatMessage(messages.orgAllValue)}
|
||||
</MenuItem>,
|
||||
<MenuItem
|
||||
key="unassigned-taxonomies"
|
||||
className="x-small"
|
||||
iconAfter={() => isOrgSelected(UNASSIGNED)}
|
||||
onClick={() => setSelectedOrgFilter(UNASSIGNED)}
|
||||
>
|
||||
{ intl.formatMessage(messages.orgUnassignedValue) }
|
||||
</MenuItem>,
|
||||
];
|
||||
|
||||
if (isOrganizationListLoaded && organizationListData) {
|
||||
organizationListData.forEach(org => (
|
||||
selectOptions.push(
|
||||
<MenuItem
|
||||
key={`${org}-taxonomies`}
|
||||
className="x-small"
|
||||
iconAfter={() => isOrgSelected(org)}
|
||||
onClick={() => setSelectedOrgFilter(org)}
|
||||
>
|
||||
{org}
|
||||
</MenuItem>,
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectMenu
|
||||
className="flex-d x-small taxonomy-orgs-filter-selector"
|
||||
variant="tertiary"
|
||||
defaultMessage={intl.formatMessage(messages.orgInputSelectDefaultValue)}
|
||||
data-testid="taxonomy-orgs-filter-selector"
|
||||
>
|
||||
{ isOrganizationListLoaded
|
||||
? selectOptions
|
||||
: (
|
||||
<Spinner
|
||||
animation="border"
|
||||
size="xl"
|
||||
screenReaderText={intl.formatMessage(messages.usageLoadingMessage)}
|
||||
/>
|
||||
)}
|
||||
</SelectMenu>
|
||||
);
|
||||
};
|
||||
|
||||
const TaxonomyListPage = () => {
|
||||
const intl = useIntl();
|
||||
const deleteTaxonomy = useDeleteTaxonomy();
|
||||
const { setToastMessage } = useContext(TaxonomyContext);
|
||||
const [selectedOrgFilter, setSelectedOrgFilter] = useState(ALL_TAXONOMIES);
|
||||
|
||||
const onDeleteTaxonomy = React.useCallback((id, name) => {
|
||||
deleteTaxonomy({ pk: id }, {
|
||||
@@ -80,17 +153,26 @@ const TaxonomyListPage = () => {
|
||||
});
|
||||
}, [setToastMessage]);
|
||||
|
||||
const {
|
||||
data: organizationListData,
|
||||
isSuccess: isOrganizationListLoaded,
|
||||
} = useOrganizationListData();
|
||||
|
||||
const useTaxonomyListData = () => {
|
||||
const taxonomyListData = useTaxonomyListDataResponse();
|
||||
const isLoaded = useIsTaxonomyListDataLoaded();
|
||||
const taxonomyListData = useTaxonomyListDataResponse(selectedOrgFilter);
|
||||
const isLoaded = useIsTaxonomyListDataLoaded(selectedOrgFilter);
|
||||
return { taxonomyListData, isLoaded };
|
||||
};
|
||||
const { taxonomyListData, isLoaded } = useTaxonomyListData();
|
||||
|
||||
const getOrgSelect = () => (
|
||||
// Organization select component
|
||||
// TODO Add functionality to this component
|
||||
undefined
|
||||
// Initialize organization select component
|
||||
<OrganizationFilterSelector
|
||||
isOrganizationListLoaded={isOrganizationListLoaded}
|
||||
organizationListData={organizationListData}
|
||||
selectedOrgFilter={selectedOrgFilter}
|
||||
setSelectedOrgFilter={setSelectedOrgFilter}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -158,6 +240,13 @@ const TaxonomyListPage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
OrganizationFilterSelector.propTypes = {
|
||||
isOrganizationListLoaded: PropTypes.bool.isRequired,
|
||||
organizationListData: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
selectedOrgFilter: PropTypes.string.isRequired,
|
||||
setSelectedOrgFilter: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
TaxonomyListPage.propTypes = {};
|
||||
|
||||
export default TaxonomyListPage;
|
||||
|
||||
7
src/taxonomy/TaxonomyListPage.scss
Normal file
7
src/taxonomy/TaxonomyListPage.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
.taxonomy-orgs-filter-selector {
|
||||
// Without this, the default bold styling for the focused option
|
||||
// in the org select menu is too thick
|
||||
.pgn__menu-item:focus {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
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 { act, render, fireEvent } from '@testing-library/react';
|
||||
|
||||
@@ -11,6 +15,8 @@ import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './data
|
||||
import { TaxonomyContext } from './common/context';
|
||||
|
||||
let store;
|
||||
let axiosMock;
|
||||
const queryClient = new QueryClient();
|
||||
const mockSetToastMessage = jest.fn();
|
||||
const mockDeleteTaxonomy = jest.fn();
|
||||
const taxonomies = [{
|
||||
@@ -18,6 +24,8 @@ const taxonomies = [{
|
||||
name: 'Taxonomy',
|
||||
description: 'This is a description',
|
||||
}];
|
||||
const organizationsListUrl = 'http://localhost:18010/organizations';
|
||||
const organizations = ['Org 1', 'Org 2'];
|
||||
|
||||
jest.mock('./data/apiHooks', () => ({
|
||||
useTaxonomyListDataResponse: jest.fn(),
|
||||
@@ -39,14 +47,16 @@ const RootWrapper = () => {
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<TaxonomyContext.Provider value={context}>
|
||||
<TaxonomyListPage intl={injectIntl} />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TaxonomyListPage intl={injectIntl} />
|
||||
</QueryClientProvider>
|
||||
</TaxonomyContext.Provider>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('<TaxonomyListPage />', async () => {
|
||||
describe('<TaxonomyListPage />', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
@@ -57,6 +67,8 @@ describe('<TaxonomyListPage />', async () => {
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(organizationsListUrl).reply(200, organizations);
|
||||
});
|
||||
|
||||
it('should render page and page title correctly', () => {
|
||||
@@ -118,4 +130,75 @@ describe('<TaxonomyListPage />', async () => {
|
||||
expect(mockDeleteTaxonomy).toBeCalledTimes(1);
|
||||
expect(mockSetToastMessage).toBeCalledWith(`"${taxonomies[0].name}" deleted`);
|
||||
});
|
||||
|
||||
it('should show all "All taxonomies", "Unassigned" and org names in taxonomy org filter', async () => {
|
||||
useIsTaxonomyListDataLoaded.mockReturnValue(true);
|
||||
useTaxonomyListDataResponse.mockReturnValue({
|
||||
results: taxonomies,
|
||||
});
|
||||
|
||||
const {
|
||||
getByTestId,
|
||||
getByText,
|
||||
getByRole,
|
||||
getAllByText,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
expect(getByTestId('taxonomy-orgs-filter-selector')).toBeInTheDocument();
|
||||
// Check that the default filter is set to 'All taxonomies' when page is loaded
|
||||
expect(getByText('All taxonomies')).toBeInTheDocument();
|
||||
|
||||
// Open the taxonomies org filter select menu
|
||||
fireEvent.click(getByRole('button', { name: 'All taxonomies' }));
|
||||
|
||||
// Check that the select menu shows 'All taxonomies' option
|
||||
// along with the default selected one
|
||||
expect(getAllByText('All taxonomies').length).toBe(2);
|
||||
// Check that the select manu shows 'Unassigned' option
|
||||
expect(getByText('Unassigned')).toBeInTheDocument();
|
||||
// Check that the select menu shows the 'Org 1' option
|
||||
expect(getByText('Org 1')).toBeInTheDocument();
|
||||
// Check that the select menu shows the 'Org 2' option
|
||||
expect(getByText('Org 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should fetch taxonomies with correct params for org filters', async () => {
|
||||
useIsTaxonomyListDataLoaded.mockReturnValue(true);
|
||||
useTaxonomyListDataResponse.mockReturnValue({
|
||||
results: taxonomies,
|
||||
});
|
||||
|
||||
const { getByRole } = render(<RootWrapper />);
|
||||
|
||||
// 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' }));
|
||||
|
||||
expect(useTaxonomyListDataResponse).toBeCalledWith('Unassigned');
|
||||
|
||||
// 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');
|
||||
|
||||
// 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');
|
||||
|
||||
// Open the taxonomies org filter select menu again
|
||||
fireEvent.click(taxonomiesFilterSelectMenu);
|
||||
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,11 @@ export const getTaxonomyListApiUrl = (org) => {
|
||||
url.searchParams.append('enabled', 'true');
|
||||
url.searchParams.append('page_size', '500'); // For the tagging MVP, we don't paginate the taxonomy list
|
||||
if (org !== undefined) {
|
||||
url.searchParams.append('org', org);
|
||||
if (org === 'Unassigned') {
|
||||
url.searchParams.append('unassigned', 'true');
|
||||
} else if (org !== 'All taxonomies') {
|
||||
url.searchParams.append('org', org);
|
||||
}
|
||||
}
|
||||
return url.href;
|
||||
};
|
||||
|
||||
@@ -45,8 +45,12 @@ describe('taxonomy api calls', () => {
|
||||
window.location = location;
|
||||
});
|
||||
|
||||
it('should get taxonomy list data with org', async () => {
|
||||
const org = 'testOrg';
|
||||
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);
|
||||
const result = await getTaxonomyListData(org);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { getTaxonomyListData, deleteTaxonomy } from './api';
|
||||
*/
|
||||
const useTaxonomyListData = (org) => (
|
||||
useQuery({
|
||||
queryKey: ['taxonomyList'],
|
||||
queryKey: ['taxonomyList', org],
|
||||
queryFn: () => getTaxonomyListData(org),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@import "taxonomy/TaxonomyListPage";
|
||||
@import "taxonomy/taxonomy-card/TaxonomyCard";
|
||||
@import "taxonomy/delete-dialog/DeleteDialog";
|
||||
@import "taxonomy/system-defined-badge/SystemDefinedBadge";
|
||||
|
||||
@@ -29,6 +29,14 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.taxonomy-list.select.org.default',
|
||||
defaultMessage: 'All taxonomies',
|
||||
},
|
||||
orgAllValue: {
|
||||
id: 'course-authoring.taxonomy-list.select.org.all',
|
||||
defaultMessage: 'All',
|
||||
},
|
||||
orgUnassignedValue: {
|
||||
id: 'course-authoring.taxonomy-list.select.org.unassigned',
|
||||
defaultMessage: 'Unassigned',
|
||||
},
|
||||
usageLoadingMessage: {
|
||||
id: 'course-authoring.taxonomy-list.spinner.loading',
|
||||
defaultMessage: 'Loading',
|
||||
|
||||
Reference in New Issue
Block a user