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:
Yusuf Musleh
2024-01-04 15:20:32 +03:00
committed by GitHub
parent 4ffebdac77
commit 278862127b
10 changed files with 227 additions and 15 deletions

View File

@@ -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) {

View 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;

View File

@@ -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;

View 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;
}
}

View File

@@ -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');
});
});

View File

@@ -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;
};

View File

@@ -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);

View File

@@ -20,7 +20,7 @@ import { getTaxonomyListData, deleteTaxonomy } from './api';
*/
const useTaxonomyListData = (org) => (
useQuery({
queryKey: ['taxonomyList'],
queryKey: ['taxonomyList', org],
queryFn: () => getTaxonomyListData(org),
})
);

View File

@@ -1,3 +1,4 @@
@import "taxonomy/TaxonomyListPage";
@import "taxonomy/taxonomy-card/TaxonomyCard";
@import "taxonomy/delete-dialog/DeleteDialog";
@import "taxonomy/system-defined-badge/SystemDefinedBadge";

View File

@@ -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',