diff --git a/src/generic/data/api.js b/src/generic/data/api.js index 7257fb689..c00e302ef 100644 --- a/src/generic/data/api.js +++ b/src/generic/data/api.js @@ -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} + * Get's organizations data. Returns list of organization names. + * @returns {Promise} */ 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} */ export async function createOrRerunCourse(courseData) { diff --git a/src/generic/data/apiHooks.js b/src/generic/data/apiHooks.js new file mode 100644 index 000000000..5640878b2 --- /dev/null +++ b/src/generic/data/apiHooks.js @@ -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; diff --git a/src/taxonomy/TaxonomyListPage.jsx b/src/taxonomy/TaxonomyListPage.jsx index c80a39332..e2cf4ac45 100644 --- a/src/taxonomy/TaxonomyListPage.jsx +++ b/src/taxonomy/TaxonomyListPage.jsx @@ -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 ? : null); + const selectOptions = [ + isOrgSelected(ALL_TAXONOMIES)} + onClick={() => setSelectedOrgFilter(ALL_TAXONOMIES)} + > + { isOrgSelected(ALL_TAXONOMIES) + ? intl.formatMessage(messages.orgInputSelectDefaultValue) + : intl.formatMessage(messages.orgAllValue)} + , + isOrgSelected(UNASSIGNED)} + onClick={() => setSelectedOrgFilter(UNASSIGNED)} + > + { intl.formatMessage(messages.orgUnassignedValue) } + , + ]; + + if (isOrganizationListLoaded && organizationListData) { + organizationListData.forEach(org => ( + selectOptions.push( + isOrgSelected(org)} + onClick={() => setSelectedOrgFilter(org)} + > + {org} + , + ) + )); + } + + return ( + + { isOrganizationListLoaded + ? selectOptions + : ( + + )} + + ); +}; + 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 + ); 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; diff --git a/src/taxonomy/TaxonomyListPage.scss b/src/taxonomy/TaxonomyListPage.scss new file mode 100644 index 000000000..b501e8a84 --- /dev/null +++ b/src/taxonomy/TaxonomyListPage.scss @@ -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; + } +} diff --git a/src/taxonomy/TaxonomyListPage.test.jsx b/src/taxonomy/TaxonomyListPage.test.jsx index c7a634d29..fdf581afa 100644 --- a/src/taxonomy/TaxonomyListPage.test.jsx +++ b/src/taxonomy/TaxonomyListPage.test.jsx @@ -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 = () => { - + + + ); }; -describe('', async () => { +describe('', () => { beforeEach(async () => { initializeMockApp({ authenticatedUser: { @@ -57,6 +67,8 @@ describe('', 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('', 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(); + + 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(); + + // 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'); + }); }); diff --git a/src/taxonomy/data/api.js b/src/taxonomy/data/api.js index 95a675ba4..be3c276e1 100644 --- a/src/taxonomy/data/api.js +++ b/src/taxonomy/data/api.js @@ -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; }; diff --git a/src/taxonomy/data/api.test.js b/src/taxonomy/data/api.test.js index b95ed9a57..dc277db8d 100644 --- a/src/taxonomy/data/api.test.js +++ b/src/taxonomy/data/api.test.js @@ -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); diff --git a/src/taxonomy/data/apiHooks.jsx b/src/taxonomy/data/apiHooks.jsx index 827057ea3..eea6d5d9f 100644 --- a/src/taxonomy/data/apiHooks.jsx +++ b/src/taxonomy/data/apiHooks.jsx @@ -20,7 +20,7 @@ import { getTaxonomyListData, deleteTaxonomy } from './api'; */ const useTaxonomyListData = (org) => ( useQuery({ - queryKey: ['taxonomyList'], + queryKey: ['taxonomyList', org], queryFn: () => getTaxonomyListData(org), }) ); diff --git a/src/taxonomy/index.scss b/src/taxonomy/index.scss index 3655a35bc..13642488e 100644 --- a/src/taxonomy/index.scss +++ b/src/taxonomy/index.scss @@ -1,3 +1,4 @@ +@import "taxonomy/TaxonomyListPage"; @import "taxonomy/taxonomy-card/TaxonomyCard"; @import "taxonomy/delete-dialog/DeleteDialog"; @import "taxonomy/system-defined-badge/SystemDefinedBadge"; diff --git a/src/taxonomy/messages.js b/src/taxonomy/messages.js index ace08d33d..7987eb525 100644 --- a/src/taxonomy/messages.js +++ b/src/taxonomy/messages.js @@ -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',