Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/generic/data/api.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// @ts-check
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';

Expand All @@ -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(
Expand All @@ -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) {
Expand Down
15 changes: 15 additions & 0 deletions src/generic/data/apiHooks.js
Original file line number Diff line number Diff line change
@@ -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;
101 changes: 95 additions & 6 deletions src/taxonomy/TaxonomyListPage.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useContext } from 'react';
import React, { useContext, useState } from 'react';
import PropTypes from 'prop-types';
import {
Button,
CardView,
Expand All @@ -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';
Expand All @@ -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 (
Expand Down Expand Up @@ -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 }, {
Expand All @@ -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 (
Expand Down Expand Up @@ -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 changes: 7 additions & 0 deletions src/taxonomy/TaxonomyListPage.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
87 changes: 85 additions & 2 deletions src/taxonomy/TaxonomyListPage.test.jsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -11,13 +15,17 @@ 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 = [{
id: 1,
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(),
Expand All @@ -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: {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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');
});
});
6 changes: 5 additions & 1 deletion src/taxonomy/data/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
8 changes: 6 additions & 2 deletions src/taxonomy/data/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
2 changes: 1 addition & 1 deletion src/taxonomy/data/apiHooks.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { getTaxonomyListData, deleteTaxonomy } from './api';
*/
const useTaxonomyListData = (org) => (
useQuery({
queryKey: ['taxonomyList'],
queryKey: ['taxonomyList', org],
queryFn: () => getTaxonomyListData(org),
})
);
Expand Down
1 change: 1 addition & 0 deletions src/taxonomy/index.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
@import "taxonomy/TaxonomyListPage";
@import "taxonomy/taxonomy-card/TaxonomyCard";
@import "taxonomy/delete-dialog/DeleteDialog";
@import "taxonomy/system-defined-badge/SystemDefinedBadge";
Expand Down
8 changes: 8 additions & 0 deletions src/taxonomy/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down